You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
How to safely handle exceptions in async context? #509
I ran into this issue: there may be multiple coroutines issuing events to the machine, yet if a coroutine triggers an invalid transition, a different coroutine may receive the exception instead.
Here is a simple example:
from statemachine import Event, State, StateMachine
import asyncio
class Test(StateMachine):
INITIAL = State(initial=True)
FINAL = State(final=True)
noop = Event(INITIAL.to(FINAL))
@noop.on
async def do_nothing(self, name):
await asyncio.sleep(10)
print(f"Did nothing via {name}")
test = Test()
if __name__ == "__main__":
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
async def fn1():
await test.noop("fn1")
# Try/Except block is not working here
async def fn2():
try:
await test.noop("fn2")
except Exception as e:
print(e)
loop.create_task(fn1())
loop.create_task(fn2())
loop.run_forever()
Output is:
➜ python3 test.py
Did nothing via fn1
Task exception was never retrieved
future: <Task finished name='Task-1' coro=<fn1() done>
exception=TransitionNotAllowed("Can't Noop when in Final.")>
Traceback (most recent call last):
File "test.py", line 29, in fn1
await test.noop("fn1")
File "/python3.12/site-packages/statemachine/engines/async_.py", line 66, in processing_loop
result = await self._trigger(trigger_data)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/python3.12/site-packages/statemachine/engines/async_.py", line 96, in _trigger
raise TransitionNotAllowed(trigger_data.event, state)
statemachine.exceptions.TransitionNotAllowed: Can't Noop when in Final.
It is clear that the invalid transition is triggered in fn2 yet the exception is thrown in fn1
While this is just a proof of concept, in my original use-case there are some places where I know that triggering a transition will not result in an exception and some other places where I know a try/catch is needed. However, if the exception is thrown in an unpredictable place I would be forced to implement exception handling everywhere and this makes stacking events complex.
Hi, thanks for the quick answer and suggestion. This seems to work well for invalid transitions. Unluckily this is not a viable option for me however, as I was using transition's validators to throw meaningful exceptions that need to be catched by the same coroutine issuing the event.
Concretely:
classTest(StateMachine):
INITIAL=State(initial=True)
FINAL=State()
noop=Event(INITIAL.to(FINAL))
noop2=Event(INITIAL.to(FINAL) |FINAL.to.itself())
@noop2.on@noop.onasyncdefdo_nothing(self, name):
awaitasyncio.sleep(5)
print(f"Did nothing via {name}")
@noop2.validatorsdefraise_exception(self):
# Do some checks ...# If checks failprint("noop2 is not allowed", flush=True)
raiseValueError("noop2 is not allowed")
test=Test(allow_event_without_transition=True)
try:
loop=asyncio.get_running_loop()
exceptRuntimeError:
loop=asyncio.new_event_loop()
asyncdeffn1():
awaittest.noop("fn1")
asyncdeffn2():
try:
awaittest.noop2("fn2")
exceptValueErrorase:
# Some logicprint(e)
loop.create_task(fn1())
loop.create_task(fn2())
loop.run_forever()
However validator exceptions too are not guaranteed to be thrown in the same coroutine so I guess i'll have to revaluate my application's flow
I'm afraid that currently this is not supported out of the box. The problem is that the event's queue is consumed by a "critical section" that only the first caller can enter, and then the queue is consumed to exhaustion. While the critical section is closed by the first caller, other callers don't block and just put the event into the queue returning immediately.
So that's why eventually the first caller gets the second event.
But our thread gives me an idea that I can work on.... I don't know if will work and probably will break the public API of the library, but looks like promising: For each caller that don't get the chance to enter the critical section, instead of returning None, I could return a Promisse-like object, like a asyncio.Future or concurrent.futures.Future. And when the event is consumed, set the future.set_result(result) or future.set_exception(exception).
In v3, the new StateChart base class changes the defaults in a way that addresses both scenarios from this issue:
Scenario 1: TransitionNotAllowed in the wrong coroutine
StateChart sets allow_event_without_transition = True by default. Events that don't match any valid transition are silently ignored instead of raising TransitionNotAllowed. So this scenario no longer causes an exception at all.
Scenario 2: Validator/callback exceptions in the wrong coroutine
StateChart sets error_on_execution = True by default. Runtime exceptions in callbacks (guards, validators, actions, entry/exit) are caught by the engine and turned into an internal error.execution event — they never propagate to any caller. You can then handle errors declaratively within the statechart:
This follows the SCXML error handling specification — errors are handled as events within the statechart rather than as exceptions propagating to callers.
The underlying architecture
The fundamental design remains the same: the processing loop uses a non-blocking lock, so only one coroutine runs the loop while others enqueue and return immediately. This is the Run-to-Completion (RTC) model — each macrostep must complete atomically before the next event is processed.
A Future-based approach (where each caller gets a future that resolves with its event's result/exception) would allow per-caller error handling, but it's a significant architectural change. For now, the v3 approach of handling errors declaratively via error.execution transitions is the recommended pattern.
This is now fixed via #567 with future-based result routing. Each concurrent caller gets its own event's result or exception — they no longer leak across coroutines.
Here's the original example from this thread, that now works for v3 (I've add a regression test at tests/testcases/test_issue509.py):
importasynciofromstatemachineimportState, StateChartclassTest(StateChart):
error_on_execution=FalseINITIAL=State(initial=True)
FINAL=State()
noop=INITIAL.to(FINAL, on="do_nothing")
noop2=INITIAL.to(FINAL, on="do_nothing", validators="raise_exception") |FINAL.to.itself(
on="do_nothing", validators="raise_exception"
)
asyncdefdo_nothing(self, name):
awaitasyncio.sleep(0.1)
returnf"Did nothing via {name}"defraise_exception(self):
raiseValueError("noop2 is not allowed")
asyncdefmain():
test=Test()
awaittest.activate_initial_state()
asyncdeffn1():
result=awaittest.send("noop", "fn1")
print(f"fn1 got: {result}") # "Did nothing via fn1"asyncdeffn2():
try:
awaittest.send("noop2", "fn2")
exceptValueErrorase:
print(f"fn2 caught: {e}") # "noop2 is not allowed"awaitasyncio.gather(
asyncio.create_task(fn1()),
asyncio.create_task(fn2()),
)
asyncio.run(main())
Now fn1 receives its own result and fn2 catches its own ValueError — regardless of which coroutine holds the processing lock.
Activity
ale18V commentedon Dec 10, 2024
While this is just a proof of concept, in my original use-case there are some places where I know that triggering a transition will not result in an exception and some other places where I know a try/catch is needed. However, if the exception is thrown in an unpredictable place I would be forced to implement exception handling everywhere and this makes stacking events complex.
fgmacedo commentedon Dec 10, 2024
Is just ignoring events that didn't result in transitions a valid option for your use case?
If so, you can just create the SM instance with
Test(allow_event_without_transition=True)Ref: https://python-statemachine.readthedocs.io/en/latest/api.html#statemachine
ale18V commentedon Dec 11, 2024
Hi, thanks for the quick answer and suggestion. This seems to work well for invalid transitions. Unluckily this is not a viable option for me however, as I was using transition's validators to throw meaningful exceptions that need to be catched by the same coroutine issuing the event.
Concretely:
However validator exceptions too are not guaranteed to be thrown in the same coroutine so I guess i'll have to revaluate my application's flow
fgmacedo commentedon Dec 11, 2024
I'm afraid that currently this is not supported out of the box. The problem is that the event's queue is consumed by a "critical section" that only the first caller can enter, and then the queue is consumed to exhaustion. While the critical section is closed by the first caller, other callers don't block and just put the event into the queue returning immediately.
So that's why eventually the first caller gets the second event.
But our thread gives me an idea that I can work on.... I don't know if will work and probably will break the public API of the library, but looks like promising: For each caller that don't get the chance to enter the critical section, instead of returning
None, I could return aPromisse-likeobject, like aasyncio.Futureorconcurrent.futures.Future. And when the event is consumed, set thefuture.set_result(result)orfuture.set_exception(exception).ale18V commentedon Dec 11, 2024
Seems like a good idea. Let me know if help is wanted
fgmacedo commentedon Feb 15, 2026
Update for v3
In v3, the new
StateChartbase class changes the defaults in a way that addresses both scenarios from this issue:Scenario 1:
TransitionNotAllowedin the wrong coroutineStateChartsetsallow_event_without_transition = Trueby default. Events that don't match any valid transition are silently ignored instead of raisingTransitionNotAllowed. So this scenario no longer causes an exception at all.Scenario 2: Validator/callback exceptions in the wrong coroutine
StateChartsetserror_on_execution = Trueby default. Runtime exceptions in callbacks (guards, validators, actions, entry/exit) are caught by the engine and turned into an internalerror.executionevent — they never propagate to any caller. You can then handle errors declaratively within the statechart:This follows the SCXML error handling specification — errors are handled as events within the statechart rather than as exceptions propagating to callers.
The underlying architecture
The fundamental design remains the same: the processing loop uses a non-blocking lock, so only one coroutine runs the loop while others enqueue and return immediately. This is the Run-to-Completion (RTC) model — each macrostep must complete atomically before the next event is processed.
A
Future-based approach (where each caller gets a future that resolves with its event's result/exception) would allow per-caller error handling, but it's a significant architectural change. For now, the v3 approach of handling errors declaratively viaerror.executiontransitions is the recommended pattern.See the error handling docs and the processing model docs for more details.
feat: add future-based result routing for async concurrent events (#509)
test: add regression test for issue #509 example
fgmacedo commentedon Feb 15, 2026
This is now fixed via #567 with future-based result routing. Each concurrent caller gets its own event's result or exception — they no longer leak across coroutines.
Here's the original example from this thread, that now works for v3 (I've add a regression test at
tests/testcases/test_issue509.py):Now
fn1receives its own result andfn2catches its ownValueError— regardless of which coroutine holds the processing lock.feat: future-based result routing for async concurrent events (#567)