Skip to content

Commit 5fa59fb

Browse files
authored
feat: Add fail_silently to handler decorators
1 parent ade4f5d commit 5fa59fb

File tree

8 files changed

+126
-53
lines changed

8 files changed

+126
-53
lines changed

cq/_core/dispatcher/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import ABC, abstractmethod
22
from collections.abc import Awaitable, Callable
3+
from contextlib import AsyncExitStack, suppress
34
from typing import Protocol, Self, runtime_checkable
45

56
from cq._core.middleware import Middleware, MiddlewareGroup
@@ -40,5 +41,12 @@ async def _invoke_with_middlewares(
4041
handler: Callable[[I], Awaitable[O]],
4142
input_value: I,
4243
/,
44+
fail_silently: bool = False,
4345
) -> O:
44-
return await self.__middleware_group.invoke(handler, input_value)
46+
async with AsyncExitStack() as stack:
47+
if fail_silently:
48+
stack.enter_context(suppress(Exception))
49+
50+
return await self.__middleware_group.invoke(handler, input_value)
51+
52+
return NotImplemented

cq/_core/dispatcher/bus.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from cq._core.dispatcher.base import BaseDispatcher, Dispatcher
99
from cq._core.handler import (
10+
HandleFunction,
1011
HandlerFactory,
1112
HandlerRegistry,
1213
MultipleHandlerRegistry,
@@ -53,10 +54,7 @@ def subscribe(self, input_type: type[I], factory: HandlerFactory[[I], O]) -> Sel
5354
self.__registry.subscribe(input_type, factory)
5455
return self
5556

56-
def _handlers_from(
57-
self,
58-
input_type: type[I],
59-
) -> Iterator[Callable[[I], Awaitable[O]]]:
57+
def _handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
6058
return self.__registry.handlers_from(input_type)
6159

6260
def _trigger_listeners(self, input_value: I, /, task_group: TaskGroup) -> None:
@@ -75,7 +73,11 @@ async def dispatch(self, input_value: I, /) -> O:
7573
self._trigger_listeners(input_value, task_group)
7674

7775
for handler in self._handlers_from(type(input_value)):
78-
return await self._invoke_with_middlewares(handler, input_value)
76+
return await self._invoke_with_middlewares(
77+
handler,
78+
input_value,
79+
handler.fail_silently,
80+
)
7981

8082
return NotImplemented
8183

@@ -95,4 +97,5 @@ async def dispatch(self, input_value: I, /) -> None:
9597
self._invoke_with_middlewares,
9698
handler,
9799
input_value,
100+
handler.fail_silently,
98101
)

cq/_core/handler.py

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -21,74 +21,96 @@ class Handler[**P, T](Protocol):
2121
__slots__ = ()
2222

2323
@abstractmethod
24-
async def handle(self, *args: P.args, **kwargs: P.kwargs) -> T:
24+
async def handle(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
2525
raise NotImplementedError
2626

2727

28+
@dataclass(repr=False, eq=False, frozen=True, slots=True)
29+
class HandleFunction[**P, T]:
30+
handler_factory: HandlerFactory[P, T]
31+
handler_type: HandlerType[P, T] | None = field(default=None)
32+
fail_silently: bool = field(default=False)
33+
34+
async def __call__(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
35+
handler = await self.handler_factory()
36+
return await handler.handle(*args, **kwargs)
37+
38+
2839
@runtime_checkable
2940
class HandlerRegistry[I, O](Protocol):
3041
__slots__ = ()
3142

3243
@abstractmethod
33-
def handlers_from(
34-
self,
35-
input_type: type[I],
36-
) -> Iterator[Callable[[I], Awaitable[O]]]:
44+
def handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
3745
raise NotImplementedError
3846

3947
@abstractmethod
40-
def subscribe(self, input_type: type[I], factory: HandlerFactory[[I], O]) -> Self:
48+
def subscribe(
49+
self,
50+
input_type: type[I],
51+
handler_factory: HandlerFactory[[I], O],
52+
handler_type: HandlerType[[I], O] | None = ...,
53+
fail_silently: bool = ...,
54+
) -> Self:
4155
raise NotImplementedError
4256

4357

4458
@dataclass(repr=False, eq=False, frozen=True, slots=True)
4559
class MultipleHandlerRegistry[I, O](HandlerRegistry[I, O]):
46-
__factories: dict[type[I], list[HandlerFactory[[I], O]]] = field(
60+
__values: dict[type[I], list[HandleFunction[[I], O]]] = field(
4761
default_factory=partial(defaultdict, list),
4862
init=False,
4963
)
5064

51-
def handlers_from(
65+
def handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
66+
for key_type in _iter_key_types(input_type):
67+
yield from self.__values.get(key_type, ())
68+
69+
def subscribe(
5270
self,
5371
input_type: type[I],
54-
) -> Iterator[Callable[[I], Awaitable[O]]]:
55-
for key_type in _iter_key_types(input_type):
56-
for factory in self.__factories.get(key_type, ()):
57-
yield _make_handle_function(factory)
72+
handler_factory: HandlerFactory[[I], O],
73+
handler_type: HandlerType[[I], O] | None = None,
74+
fail_silently: bool = False,
75+
) -> Self:
76+
function = HandleFunction(handler_factory, handler_type, fail_silently)
5877

59-
def subscribe(self, input_type: type[I], factory: HandlerFactory[[I], O]) -> Self:
6078
for key_type in _build_key_types(input_type):
61-
self.__factories[key_type].append(factory)
79+
self.__values[key_type].append(function)
6280

6381
return self
6482

6583

6684
@dataclass(repr=False, eq=False, frozen=True, slots=True)
6785
class SingleHandlerRegistry[I, O](HandlerRegistry[I, O]):
68-
__factories: dict[type[I], HandlerFactory[[I], O]] = field(
86+
__values: dict[type[I], HandleFunction[[I], O]] = field(
6987
default_factory=dict,
7088
init=False,
7189
)
7290

73-
def handlers_from(
74-
self,
75-
input_type: type[I],
76-
) -> Iterator[Callable[[I], Awaitable[O]]]:
91+
def handlers_from(self, input_type: type[I]) -> Iterator[HandleFunction[[I], O]]:
7792
for key_type in _iter_key_types(input_type):
78-
factory = self.__factories.get(key_type, None)
79-
if factory is not None:
80-
yield _make_handle_function(factory)
93+
function = self.__values.get(key_type, None)
94+
if function is not None:
95+
yield function
8196

82-
def subscribe(self, input_type: type[I], factory: HandlerFactory[[I], O]) -> Self:
83-
entries = {key_type: factory for key_type in _build_key_types(input_type)}
97+
def subscribe(
98+
self,
99+
input_type: type[I],
100+
handler_factory: HandlerFactory[[I], O],
101+
handler_type: HandlerType[[I], O] | None = None,
102+
fail_silently: bool = False,
103+
) -> Self:
104+
function = HandleFunction(handler_factory, handler_type, fail_silently)
105+
entries = {key_type: function for key_type in _build_key_types(input_type)}
84106

85107
for key_type in entries:
86-
if key_type in self.__factories:
108+
if key_type in self.__values:
87109
raise RuntimeError(
88110
f"A handler is already registered for the input type: `{key_type}`."
89111
)
90112

91-
self.__factories.update(entries)
113+
self.__values.update(entries)
92114
return self
93115

94116

@@ -105,6 +127,7 @@ def __call__(
105127
input_or_handler_type: type[I],
106128
/,
107129
*,
130+
fail_silently: bool = ...,
108131
threadsafe: bool | None = ...,
109132
) -> Decorator: ...
110133

@@ -114,6 +137,7 @@ def __call__[T](
114137
input_or_handler_type: T,
115138
/,
116139
*,
140+
fail_silently: bool = ...,
117141
threadsafe: bool | None = ...,
118142
) -> T: ...
119143

@@ -123,6 +147,7 @@ def __call__(
123147
input_or_handler_type: None = ...,
124148
/,
125149
*,
150+
fail_silently: bool = ...,
126151
threadsafe: bool | None = ...,
127152
) -> Decorator: ...
128153

@@ -131,18 +156,24 @@ def __call__[T](
131156
input_or_handler_type: type[I] | T | None = None,
132157
/,
133158
*,
159+
fail_silently: bool = False,
134160
threadsafe: bool | None = None,
135161
) -> Any:
136162
if (
137163
input_or_handler_type is not None
138164
and isclass(input_or_handler_type)
139165
and issubclass(input_or_handler_type, Handler)
140166
):
141-
return self.__decorator(input_or_handler_type, threadsafe=threadsafe)
167+
return self.__decorator(
168+
input_or_handler_type,
169+
fail_silently=fail_silently,
170+
threadsafe=threadsafe,
171+
)
142172

143173
return partial(
144174
self.__decorator,
145175
input_type=input_or_handler_type, # type: ignore[arg-type]
176+
fail_silently=fail_silently,
146177
threadsafe=threadsafe,
147178
)
148179

@@ -152,11 +183,12 @@ def __decorator(
152183
/,
153184
*,
154185
input_type: type[I] | None = None,
186+
fail_silently: bool = False,
155187
threadsafe: bool | None = None,
156188
) -> HandlerType[[I], O]:
157189
factory = self.injection_module.make_async_factory(wrapped, threadsafe)
158190
input_type = input_type or _resolve_input_type(wrapped)
159-
self.registry.subscribe(input_type, factory)
191+
self.registry.subscribe(input_type, factory, wrapped, fail_silently)
160192
return wrapped
161193

162194

@@ -190,14 +222,3 @@ def _resolve_input_type[I, O](handler_type: HandlerType[[I], O]) -> type[I]:
190222
f"Unable to resolve input type for handler `{handler_type}`, "
191223
"`handle` method must have a type annotation for its first parameter."
192224
)
193-
194-
195-
def _make_handle_function[I, O](
196-
factory: HandlerFactory[[I], O],
197-
) -> Callable[[I], Awaitable[O]]:
198-
return partial(__handle, factory=factory)
199-
200-
201-
async def __handle[I, O](input_value: I, *, factory: HandlerFactory[[I], O]) -> O:
202-
handler = await factory()
203-
return await handler.handle(input_value)

cq/middlewares/retry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(
2525
self.__exceptions = tuple(exceptions)
2626
self.__retry = retry
2727

28-
async def __call__(self, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
28+
async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
2929
retry = self.__retry
3030

3131
for attempt in range(1, retry + 1):

cq/middlewares/scope.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class InjectionScopeMiddleware:
1919
exist_ok: bool = field(default=False, kw_only=True)
2020
threadsafe: bool | None = field(default=None, kw_only=True)
2121

22-
async def __call__(self, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
22+
async def __call__(self, /, *args: Any, **kwargs: Any) -> MiddlewareResult[Any]:
2323
async with AsyncExitStack() as stack:
2424
try:
2525
await stack.enter_async_context(

docs/guides/messages.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,19 @@ class TrackUserCreatedHandler(NamedTuple):
142142
async def handle(self, event: UserCreatedEvent):
143143
...
144144
```
145+
146+
### fail_silently
147+
148+
The `fail_silently` option suppresses any exception raised by the handler instead of propagating it to the caller. Exceptions can still be caught and handled by middlewares before being suppressed.
149+
150+
This is particularly useful for non-critical event handlers where a failure should not affect the rest of the system:
151+
152+
```python
153+
@event_handler(fail_silently=True)
154+
class TrackUserCreatedHandler(NamedTuple):
155+
analytics: AnalyticsService
156+
157+
async def handle(self, event: UserCreatedEvent):
158+
# An exception here won't propagate to the caller
159+
...
160+
```

tests/test_command_bus.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,14 @@ async def handle(self, command: _Command) -> None:
3333
assert len(history.records) == 2
3434
assert isinstance(history.records[0].args[0], _Event)
3535
assert isinstance(history.records[1].args[0], _Command)
36+
37+
async def test_dispatch_with_fail_silently(self) -> None:
38+
class _Command: ...
39+
40+
@command_handler(fail_silently=True)
41+
class _CommandHandler:
42+
async def handle(self, command: _Command) -> None:
43+
raise ValueError
44+
45+
command_bus = find_instance(AnyCommandBus)
46+
assert await command_bus.dispatch(_Command()) is NotImplemented

uv.lock

Lines changed: 20 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)