Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ jobs:
working-directory: sdk
run: poetry run ruff format --check .

- name: Run mypy type checker
working-directory: sdk
run: poetry run mypy

changelog:
runs-on: ubuntu-latest
name: Changelog check
Expand Down
32 changes: 32 additions & 0 deletions sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Static type checking with mypy**: the `eggai` package is now type-checked in CI
(`poetry run mypy`). A pragmatic first-adoption config (`ignore_missing_imports`,
`warn_unused_ignores`) lives in `pyproject.toml`; `mypy` is a new dev dependency.

### Fixed
- **A2A executor**: the JSON-serialisation fallback caught a non-existent
`json.JSONEncodeError`, so when `json.dumps` failed the `except` clause itself
raised `AttributeError` and masked the real error. Now catches `(TypeError,
ValueError)`, the exceptions `json.dumps` actually raises.
- **PEL reclaimer**: data-plane Redis calls now go through a `_client` accessor that
raises a clear error if used before `start()`, instead of an opaque
`AttributeError` on `None`.
- **Typed subscriptions**: a `data_type` whose `type` field has no default (e.g. raw
`BaseMessage`, where `type` is required) silently dropped *every* message, because
the discriminator was compared against `PydanticUndefined`. This now raises a clear
`ValueError` at subscribe time telling you to give `type` a default discriminator.
- **Agent plugins**: passing plugin-prefixed kwargs (e.g. `a2a_*`) to `subscribe()`
without initializing that plugin via the `Agent(...)` constructor raised an opaque
`KeyError`; it now raises a `ValueError` explaining the plugin is not initialized.
- **A2A executor**: the "unknown skill" error path enqueued a raw dict instead of a
proper A2A message; it now uses the same `_send_agent_response` wrapper as every
other response path.
- **A2A executor**: a skill registered with `data_type=None` raised `TypeError`
(`None(...)`) when building the message; the type lookup is now done once and
falls back to the generic `BaseMessage` when no data type is set.

### Changed
- **Dev dependencies**: bumped `pytest` (9.0.3 → 9.1.0) and `ruff` (0.15.x patch).
- Minor internal type-annotation cleanups across the transports, channel, agent,
and hooks to satisfy mypy (no behaviour change).

## [0.3.2] - 2026-06-15

### Fixed
Expand Down
25 changes: 11 additions & 14 deletions sdk/eggai/adapters/a2a/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,25 @@
logger.info(f"Executing A2A skill: {skill_name}")

if skill_name not in self.a2a_plugin.handlers:
error_msg = f"Unknown skill: {skill_name}. Available skills: {list(self.a2a_plugin.handlers.keys())}"

Check warning on line 36 in sdk/eggai/adapters/a2a/executor.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: security

A2A error response leaks the complete list of registered skill handler names to any caller, enabling full skill-surface enumeration.
logger.error(error_msg)

Check notice on line 37 in sdk/eggai/adapters/a2a/executor.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: security

Untrusted `skill_name` from the A2A protocol payload is interpolated directly into a `logger.error()` call without newline sanitization, enabling log injection.
await event_queue.enqueue_event({"text": error_msg})
# Use the same A2A-message wrapper as every other response path,
# rather than enqueuing a raw dict the event queue can't model.
await self._send_agent_response(event_queue, {"error": error_msg})
return

# Get handler and extract message data
handler = self.a2a_plugin.handlers[skill_name]
message_data = self._extract_message_data(request_context)

# Create proper BaseMessage with validated data
# Validate the raw data against the skill's declared type (if any) and
# wrap it in the BaseMessage subclass the handler expects. Looked up once;
# data_type may be None (a skill can register without one), which falls
# through to the generic BaseMessage rather than calling None(...).
validated_data = message_data
if skill_name in self.a2a_plugin.data_types:
data_type = self.a2a_plugin.data_types[skill_name]
if (
data_type
and hasattr(data_type, "__args__")
and len(data_type.__args__) > 0
):
data_type = self.a2a_plugin.data_types.get(skill_name)
if data_type is not None:
if hasattr(data_type, "__args__") and len(data_type.__args__) > 0:

Check warning on line 54 in sdk/eggai/adapters/a2a/executor.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

When `data_type` has `__args__` but validation fails, the code still attempts to instantiate `data_type(source=..., type=..., data=validated_data)` with the raw dict, which may raise a TypeError or validation error if the constructor expects a validated model instance.
# Extract inner type from BaseMessage[T] and validate the raw data
inner_type = data_type.__args__[0]
if hasattr(inner_type, "model_validate"):
Expand All @@ -64,10 +65,6 @@
f"Failed to validate data as {inner_type.__name__}: {e}"
)
# Keep as dict if validation fails

# Create the proper BaseMessage structure that the handler expects
if skill_name in self.a2a_plugin.data_types:
data_type = self.a2a_plugin.data_types[skill_name]
# Create instance of the specific BaseMessage subclass
eggai_message = data_type(
source="a2a-client",
Expand Down Expand Up @@ -216,6 +213,6 @@
# Fallback: try to send as simple event
try:
await event_queue.enqueue_event({"text": json.dumps(data)})
except (json.JSONEncodeError, TypeError) as json_err:
except (TypeError, ValueError) as json_err:
logger.error(f"Failed to serialize response data: {json_err}")
raise
2 changes: 1 addition & 1 deletion sdk/eggai/adapters/a2a/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
self.config = None
self.skills: dict[str, Any] = {} # AgentSkill objects
self.handlers: dict[str, Callable] = {} # Handler functions
self.data_types: dict[str, type] = {} # Data types for conversion
self.data_types: dict[str, type | None] = {} # Data types for conversion

def init(
self,
Expand All @@ -47,7 +47,7 @@
"a2a_capability must be a string representing the skill name"
)
data_type = kwargs.get("data_type")
if data_type is None:

Check warning on line 50 in sdk/eggai/adapters/a2a/plugin.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

subscribe() raises ValueError when data_type is None, but register_skill() accepts data_type: type | None, making the None case unreachable through the normal path and the error message misleading.
raise ValueError("data_type must be provided for A2A skills")
self.register_skill(a2a_capability, handler, data_type)

Expand Down
28 changes: 20 additions & 8 deletions sdk/eggai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .transport import get_default_transport
from .transport.base import Transport

HANDLERS_IDS = defaultdict(int)
HANDLERS_IDS: defaultdict[str, int] = defaultdict(int)

PLUGIN_LIST = ["a2a"]

Expand Down Expand Up @@ -43,7 +43,7 @@
self._stop_registered = False

# Initialize plugins system
self.plugins = {}
self.plugins: dict[Any, dict[str, Any]] = {}

# Initialize plugins generically
for plugin_name in PLUGIN_LIST:
Expand Down Expand Up @@ -129,16 +129,28 @@
for plugin_name in PLUGIN_LIST:
for key in list(kwargs.keys()):
if key.startswith(plugin_name):
kwargs.pop(key)

Check warning on line 132 in sdk/eggai/agent.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

kwargs dict is mutated in-place by popping plugin-prefixed keys, permanently altering the closure variable shared across all calls to the decorator for the same subscription.
plugin_found_keys.add(plugin_name)

self._subscriptions.append((channel_name, handler, kwargs))

# Call plugin subscribe methods if they have relevant kwargs
# Call plugin subscribe methods if they have relevant kwargs. Validate
# before registering the subscription so a misconfiguration doesn't leave
# a half-registered handler in self._subscriptions.
for plugin_found_key in plugin_found_keys:
self.plugins[plugin_found_key]["_instance"].subscribe(
channel_name, handler, **original_kwargs
)
plugin = self.plugins.get(plugin_found_key)
if plugin is None or "_instance" not in plugin:
# Plugin-prefixed kwargs were passed to subscribe(), but the
# plugin is only instantiated when matching kwargs are passed to
# Agent(...). Without that, self.plugins has no entry and the
# access below would raise an opaque KeyError.
raise ValueError(

Check notice on line 145 in sdk/eggai/agent.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: bug

The error message in the new `ValueError` at line 145 leaks the internal `_instance` sentinel key name and gives misleading guidance when the plugin name itself is not in `PLUGIN_LIST`.
f"Subscription passed {plugin_found_key!r}-prefixed kwargs, "
f"but the {plugin_found_key!r} plugin is not initialized on "
f"this agent. Pass {plugin_found_key!r} configuration to the "
"Agent(...) constructor to enable it."
)
plugin["_instance"].subscribe(channel_name, handler, **original_kwargs)

self._subscriptions.append((channel_name, handler, kwargs))

return handler

Expand Down
4 changes: 2 additions & 2 deletions sdk/eggai/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .transport import get_default_transport
from .transport.base import Transport

HANDLERS_IDS = defaultdict(int)
HANDLERS_IDS: defaultdict[str, int] = defaultdict(int)

# Environment variable: EGGAI_NAMESPACE
# Purpose: Prefix for all channel names to enable namespace isolation in shared transports.
Expand All @@ -29,7 +29,7 @@
Connection is established lazily on the first publish or subscription.
"""

def __init__(self, name: str = None, transport: Transport | None = None):
def __init__(self, name: str | None = None, transport: Transport | None = None):
"""
Initialize a Channel instance.

Expand Down Expand Up @@ -101,7 +101,7 @@
)
HANDLERS_IDS[handler_name] += 1
kwargs["handler_id"] = f"{handler_name}-{HANDLERS_IDS[handler_name]}"
await self._get_transport().subscribe(self._name, callback, **kwargs)

Check failure on line 104 in sdk/eggai/channel.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

HIGH: bug

Transport is subscribed before the connection is established, which may cause the subscription to fail or behave incorrectly if the transport requires an active connection first.
await self._ensure_connected()

async def ensure_exists(self):
Expand Down
2 changes: 1 addition & 1 deletion sdk/eggai/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
F = TypeVar("F", bound=Callable[..., Coroutine[Any, Any, Any]])

# Global variables for shutdown handling.
_STOP_CALLBACKS = []
_STOP_CALLBACKS: list[Callable[..., Any]] = []
_EXIT_EVENT = None # will be lazily created
_SIGNAL_HANDLERS_INSTALLED = False
_CLEANUP_STARTED = False
Expand Down Expand Up @@ -76,7 +76,7 @@
# [task.cancel() for task in tasks]
# await asyncio.gather(*tasks)

global _SIGNAL_HANDLERS_INSTALLED

Check warning on line 79 in sdk/eggai/hooks.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

`_SIGNAL_HANDLERS_INSTALLED` is never set to `True` after installing signal handlers, allowing duplicate handler registration.
if _SIGNAL_HANDLERS_INSTALLED:
return

Expand All @@ -87,7 +87,7 @@
sig, lambda s=sig: asyncio.create_task(shutdown(sig, False))
)
except NotImplementedError:
signal.signal(sig, lambda _, __: asyncio.create_task(shutdown(sig, True)))

Check failure on line 90 in sdk/eggai/hooks.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

HIGH: bug

Signal handler lambda captures `sig` loop variable incorrectly, always using the last value of `sig` in the fallback `signal.signal` path.


def eggai_main(func: F) -> F:
Expand Down
4 changes: 3 additions & 1 deletion sdk/eggai/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@
description="W3C traceparent for distributed trace context propagation.",
)
data: TData = Field(
default_factory=dict,
# dict default for the common unparameterised case; concrete subclasses
# bind TData (e.g. to dict) — the factory can't be generic over TData.
default_factory=dict, # type: ignore[assignment]

Check warning on line 71 in sdk/eggai/schemas.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

Using `dict` as the default_factory for a generic `TData` field silently produces an incorrect default when `TData` is not `dict` (e.g., a Pydantic model or a list), potentially bypassing validation or producing a wrong-typed value at runtime.
description="Event payload containing application-specific data.",
)

Expand Down
1 change: 1 addition & 0 deletions sdk/eggai/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
return _backend


def setup_tracing(

Check warning on line 103 in sdk/eggai/tracing.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

`setup_tracing` does not guard against being called multiple times, so each call adds another `BatchSpanProcessor` to the same provider, causing duplicate spans.
exporter="otlp",
*,
service_name: str | None = None,
Expand All @@ -127,7 +127,7 @@
span_exporter = OTLPSpanExporter(**({"endpoint": endpoint} if endpoint else {}))
elif hasattr(exporter, "export"):
span_exporter = exporter # accept pre-built exporter object (for testing)
else: # "otlp" / "otlp-grpc" (default)

Check notice on line 130 in sdk/eggai/tracing.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: security

Unvalidated `exporter` string falls through to the gRPC OTLP exporter default branch without any warning or error, which could silently misconfigure tracing.
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
Expand All @@ -141,6 +141,7 @@
if isinstance(trace.get_tracer_provider(), ProxyTracerProvider):
trace.set_tracer_provider(provider)
else:
# Adopt the already-configured provider (API base type, not the SDK one).

Check warning on line 144 in sdk/eggai/tracing.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

When an existing TracerProvider is already set, the locally-built `provider` (with its BatchSpanProcessor) is silently discarded but the span processor is never shut down, leaking background threads/resources.
provider = trace.get_tracer_provider()

from importlib.metadata import version
Expand All @@ -151,7 +152,7 @@

def apply_traceparent(message: Any, traceparent: str) -> Any:
"""Return message with traceparent set (creates new Pydantic model instance or dict copy)."""
from pydantic import BaseModel

Check warning on line 155 in sdk/eggai/tracing.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: maintainability

Pydantic is imported inside `apply_traceparent` on every call, adding repeated import overhead and a hidden hard dependency on Pydantic.

if isinstance(message, BaseModel):
return message.model_copy(update={"traceparent": traceparent})
Expand Down Expand Up @@ -206,7 +207,7 @@


# Auto-activate if OTEL env vars present (silently skip if otel extra not installed)
if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):

Check warning on line 210 in sdk/eggai/tracing.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

Module-level auto-activation of tracing at import time can cause side effects and race conditions for consumers who import the module before calling setup_tracing themselves.
try:
_protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
_exporter = "otlp-http" if "http" in _protocol else "otlp"
Expand Down
2 changes: 1 addition & 1 deletion sdk/eggai/transport/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async def publish(self, channel: str, message: dict[str, Any] | BaseModel):
async def subscribe(
self,
channel: str,
callback: Callable[[dict[str, Any]], "asyncio.Future"],
handler: Callable[[dict[str, Any]], "asyncio.Future"],
**kwargs,
) -> Callable:
"""
Expand Down
1 change: 1 addition & 0 deletions sdk/eggai/transport/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@
"No default transport factory set, InMemoryTransport will be used. "
"Use eggai_set_default_transport() to set a different default transport."
)
eggai_set_default_transport(lambda: InMemoryTransport())

Check notice on line 32 in sdk/eggai/transport/defaults.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: bug

The warning is logged and the factory is set to InMemoryTransport on every call when no factory has been explicitly configured, but after the first call the factory is set globally, silently changing the default for all subsequent callers without warning.
assert _DEFAULT_TRANSPORT_FACTORY is not None # set just above if it was None

Check notice on line 33 in sdk/eggai/transport/defaults.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: bug

`assert` is used as a runtime null-safety guard in `get_default_transport()`; Python's `-O` flag strips all `assert` statements, turning the guard into dead code and exposing a latent `TypeError: 'NoneType' object is not callable`.

Check notice on line 33 in sdk/eggai/transport/defaults.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: maintainability

Using assert to guard against a condition that was just set in the same function is unnecessary and will be silently removed if Python is run with the -O (optimize) flag.

Check notice on line 33 in sdk/eggai/transport/defaults.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

LOW: maintainability

The assert on line 33 is redundant and will be stripped in optimized builds (-O flag), making it an unreliable guard.
return _DEFAULT_TRANSPORT_FACTORY()
9 changes: 5 additions & 4 deletions sdk/eggai/transport/inmemory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from collections.abc import Callable
from typing import Any

from eggai.schemas import BaseMessage
from pydantic import BaseModel

from eggai.transport import Transport
from eggai.transport.middleware_utils import wrap_handler_with_filters

Expand Down Expand Up @@ -90,7 +91,7 @@ async def disconnect(self):
self._consume_tasks.clear()
self._connected = False

async def publish(self, channel: str, message: dict[str, Any] | BaseMessage):
async def publish(self, channel: str, message: dict[str, Any] | BaseModel):
"""
Publishes a message to the given channel.
The message is put into all queues for that channel so each consumer group receives it.
Expand All @@ -111,7 +112,7 @@ async def publish(self, channel: str, message: dict[str, Any] | BaseMessage):
async def subscribe(
self,
channel: str,
callback: Callable[[dict[str, Any]], "asyncio.Future"],
handler: Callable[[dict[str, Any]], "asyncio.Future"],
**kwargs,
):
"""
Expand All @@ -129,7 +130,7 @@ async def subscribe(
# and typed delivery identical across all transports and is the single place
# that rejects invalid filter-option combinations. Tracing stays outermost.
final_callback = wrap_handler_with_filters(
callback,
handler,
data_type=kwargs.get("data_type"),
filter_by_data=kwargs.get("filter_by_data"),
filter_by_message=kwargs.get("filter_by_message"),
Expand Down
10 changes: 7 additions & 3 deletions sdk/eggai/transport/kafka.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from aiokafka.admin import AIOKafkaAdminClient, NewTopic
from aiokafka.errors import TopicAlreadyExistsError
from faststream.kafka import KafkaBroker
from pydantic import BaseModel

from eggai.schemas import BaseMessage
from eggai.transport.base import Transport
from eggai.transport.middleware_utils import wrap_handler_with_filters

Expand Down Expand Up @@ -129,7 +129,7 @@
await admin.create_topics([new_topic])
except TopicAlreadyExistsError:
pass
except Exception:

Check warning on line 132 in sdk/eggai/transport/kafka.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: maintainability

Broad `except Exception: pass` silently swallows all errors during topic creation, making failures completely invisible.
pass
finally:
await admin.close()
Expand All @@ -141,7 +141,11 @@
and self.broker._producer
):
try:
await self.broker._producer._producer.client.force_metadata_update()
# Reaches into aiokafka internals via FastStream's producer — there

Check warning on line 144 in sdk/eggai/transport/kafka.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

The `_topics_to_create` set is used as a 'topics already ensured' cache, but the set is populated even when `_ensure_topic` raises an unhandled exception, meaning a failed topic creation is silently marked as succeeded.
# is no public API for force_metadata_update(). Guarded by the
# hasattr check above and a broad except so a FastStream internals
# change degrades gracefully (stale metadata, not a crash).
await self.broker._producer._producer.client.force_metadata_update() # type: ignore[attr-defined]

Check failure on line 148 in sdk/eggai/transport/kafka.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

HIGH: performance

The `force_metadata_update()` call is made synchronously on the internal aiokafka client without any timeout, which can block the event loop if the Kafka broker is slow or unreachable.

Check warning on line 148 in sdk/eggai/transport/kafka.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: maintainability

Deep access into private aiokafka internals (`self.broker._producer._producer.client.force_metadata_update()`) creates a fragile coupling to undocumented implementation details.
except Exception:
pass

Expand All @@ -151,7 +155,7 @@
await self._ensure_topic(channel)
self._topics_to_create.add(channel)

async def publish(self, channel: str, message: dict[str, Any] | BaseMessage):
async def publish(self, channel: str, message: dict[str, Any] | BaseModel):
"""
Publishes a message to the specified Kafka topic (channel).

Expand Down
17 changes: 14 additions & 3 deletions sdk/eggai/transport/middleware_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from collections.abc import Callable
from typing import Any

from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from pydantic_core import PydanticUndefined

Check warning on line 18 in sdk/eggai/transport/middleware_utils.py

View workflow job for this annotation

GitHub Actions / QualOps Code Quality

MEDIUM: bug

Importing `PydanticUndefined` from `pydantic_core` instead of `pydantic` may break on certain Pydantic v2 installations or future versions where the public re-export path changes.

# Identity attributes copied from the user handler onto a wrapper. We intentionally
# do NOT use functools.wraps here: it sets __wrapped__, which inspect.signature
Expand Down Expand Up @@ -52,7 +53,7 @@
def wrap_handler_with_filters(
handler: Callable,
*,
data_type: type | None = None,
data_type: type[BaseModel] | None = None,
filter_by_data: Callable[[Any], bool] | None = None,
filter_by_message: Callable[[dict[str, Any]], bool] | None = None,
) -> Callable:
Expand Down Expand Up @@ -98,14 +99,24 @@
"(the discriminator used to match messages, as on BaseMessage)."
)
expected_type = data_type.model_fields["type"].default
if expected_type is PydanticUndefined:
# The 'type' field exists but has no default, so there is no
# discriminator value to match against — every message would fail the
# check at line below and be silently dropped. Fail loudly instead.
raise ValueError(
f"data_type {data_type.__name__!r} has a 'type' field with no "
"default, so there is no value to match messages against (every "
"message would be silently dropped). Give it a default discriminator, "
'e.g. `type: str = "OrderCreated"`.'
)

async def typed_handler(message: dict[str, Any]) -> Any:
try:
typed_message = data_type.model_validate(message)
except (ValidationError, ValueError, TypeError):
# Wrong shape / payload for this data_type — not ours to handle.
return None
if typed_message.type != expected_type:
if getattr(typed_message, "type", None) != expected_type:
return None
if filter_by_data is not None and not filter_by_data(typed_message):
return None
Expand Down
Loading
Loading