Skip to content

Commit 246c1f9

Browse files
committed
docs(error_handling): rewrite with SCXML-first narrative and inline examples
Reorganize for StateChart users (SCXML-compliant by default): - Open with practical question, ref behaviour.md for configuration - Promote block-level catching as first concept (prerequisite) - Consolidate naming convention to one example (remove near-duplicate) - Add inline doctest for cleanup/finalize pattern (success + failure) - Add section on validators not triggering error events - Remove "Two models" / "When to use which" (redundant with behaviour.md)
1 parent a5a4050 commit 246c1f9

1 file changed

Lines changed: 174 additions & 126 deletions

File tree

docs/error_handling.md

Lines changed: 174 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2,197 +2,245 @@
22
(error-handling)=
33
# Error handling
44

5-
When callbacks raise exceptions during a transition, the state machine needs
6-
a strategy. This library supports two models: **exception propagation** (the
7-
traditional approach) and **error events** (the SCXML-compliant approach).
5+
```{seealso}
6+
New to statecharts? See [](concepts.md) for an overview of how states,
7+
transitions, events, and actions fit together.
8+
```
89

9-
## Two models
10+
What happens when a callback raises an exception during a transition?
1011

11-
| Approach | `error_on_execution` | Behavior |
12-
|-----------------------|----------------------|-------------------------------------------------------|
13-
| Exception propagation | `False` | Exceptions bubble up to the caller like normal Python. |
14-
| Error events | `True` | Exceptions are caught and dispatched as `error.execution` internal events. |
12+
With `StateChart`, errors in actions are caught by the engine and dispatched
13+
as `error.execution` internal events — so the machine itself can react to
14+
failures by transitioning to an error state, retrying, or recovering. This
15+
follows the [SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents).
1516

16-
`StateChart` uses `error_on_execution = True` by default — the SCXML-compliant
17-
behavior. You can switch to exception propagation by overriding the attribute:
17+
```{tip}
18+
`error_on_execution` is a class attribute that controls this behavior.
19+
`StateChart` uses `True` by default (SCXML-compliant); set it to `False`
20+
to let exceptions propagate to the caller instead. See {ref}`behaviour`
21+
for the full comparison of behavioral attributes and how to customize them.
22+
```
1823

19-
```python
20-
from statemachine import StateChart
2124

22-
class MyChart(StateChart):
23-
error_on_execution = False # exceptions propagate to the caller
24-
```
25+
(error-execution)=
2526

26-
### When to use which
27+
## How errors are caught
2728

28-
**Exception propagation** (`error_on_execution = False`) is simpler and
29-
familiar to most Python developers. It works well for flat state machines
30-
where errors should stop execution immediately and be handled by the caller
31-
with `try/except`.
29+
When an action raises during a {ref}`microstep <macrostep-microstep>`, the
30+
engine catches the exception at the **block level**. Each phase of the
31+
microstep is an independent block:
3232

33-
**Error events** (`error_on_execution = True`) are the SCXML standard and
34-
the recommended approach for statecharts. They allow the machine to handle
35-
errors as part of its own logic — transitioning to error states, retrying,
36-
or recovering — without leaking implementation details to the caller. This
37-
is especially powerful in hierarchical machines where different states may
38-
handle errors differently.
33+
| Block | Callbacks |
34+
|---|---|
35+
| Exit | `on_exit_state()`, `on_exit_<state>()` |
36+
| On | `on_transition()`, `on_<event>()` |
37+
| Enter | `on_enter_state()`, `on_enter_<state>()` |
3938

39+
An error in one block:
4040

41-
(error-execution)=
42-
## Error events with `error.execution`
41+
- **Stops remaining actions in that block** — per the SCXML spec, execution
42+
MUST NOT continue within the same block after an error.
43+
- **Does not affect other blocks** — subsequent phases of the microstep
44+
still execute. In particular, **`after` callbacks always run** regardless
45+
of errors in earlier blocks.
4346

44-
When `error_on_execution` is `True`, runtime exceptions during transitions
45-
are caught by the engine and result in an internal `error.execution` event
46-
being placed on the queue. This follows the
47-
[SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents).
47+
This means that even if a transition's `on` action raises, the target states
48+
are still entered and `after_<event>()` callbacks still run. The error is
49+
caught and queued as an `error.execution` internal event that fires within
50+
the same {ref}`macrostep <macrostep-microstep>`.
4851

49-
You can define transitions for this event to gracefully handle errors within
50-
the state machine itself.
52+
```{note}
53+
`before` callbacks run before any state changes, so an error in `before`
54+
prevents the transition from executing — but `after` still runs because
55+
it belongs to a separate block.
56+
```
57+
58+
59+
## The `error.execution` event
60+
61+
After catching an error, the engine places an `error.execution` event on the
62+
internal queue. You can define transitions for this event to handle errors
63+
within the state machine itself — transitioning to error states, logging, or
64+
recovering.
5165

5266
### The `error_` naming convention
5367

54-
Since Python identifiers cannot contain dots, the library provides a naming
55-
convention: any event attribute starting with `error_` automatically matches
56-
both the underscore form and the dot-notation form. For example,
57-
`error_execution` matches both `"error_execution"` and `"error.execution"`.
68+
Since Python identifiers cannot contain dots, any event attribute starting
69+
with `error_` automatically matches both the underscore and dot-notation
70+
forms. For example, `error_execution` matches both `"error_execution"` and
71+
`"error.execution"`:
5872

5973
```py
6074
>>> from statemachine import State, StateChart
6175

62-
>>> class MyChart(StateChart):
63-
... s1 = State("s1", initial=True)
64-
... error_state = State("error_state", final=True)
76+
>>> class ResilientChart(StateChart):
77+
... operational = State(initial=True)
78+
... broken = State(final=True)
6579
...
66-
... go = s1.to(s1, on="bad_action")
67-
... error_execution = s1.to(error_state)
80+
... do_work = operational.to(operational, on="risky_action")
81+
... error_execution = operational.to(broken)
6882
...
69-
... def bad_action(self):
83+
... def risky_action(self):
7084
... raise RuntimeError("something went wrong")
7185

72-
>>> sm = MyChart()
73-
>>> sm.send("go")
74-
>>> sm.configuration == {sm.error_state}
86+
>>> sm = ResilientChart()
87+
>>> sm.send("do_work")
88+
>>> "broken" in sm.configuration_values
7589
True
7690

7791
```
7892

79-
This is equivalent to the more verbose explicit form:
80-
81-
```python
82-
error_execution = Event(s1.to(error_state), id="error.execution")
93+
```{note}
94+
If you provide an explicit `id=` parameter on the `Event`, it takes
95+
precedence and the naming convention is not applied.
8396
```
8497

85-
The convention works with both bare transitions and `Event` objects without
86-
an explicit `id`:
98+
### Accessing error data
99+
100+
The original exception is available as `error` in the keyword arguments
101+
of callbacks on the `error.execution` transition. Use
102+
{ref}`dependency injection <dependency-injection>` to receive it:
87103

88104
```py
89-
>>> from statemachine import Event, State, StateChart
105+
>>> from statemachine import State, StateChart
90106

91-
>>> class ChartWithEvent(StateChart):
92-
... s1 = State("s1", initial=True)
93-
... error_state = State("error_state", final=True)
107+
>>> class ErrorLogger(StateChart):
108+
... running = State(initial=True)
109+
... failed = State(final=True)
94110
...
95-
... go = s1.to(s1, on="bad_action")
96-
... error_execution = Event(s1.to(error_state))
111+
... process = running.to(running, on="do_process")
112+
... error_execution = running.to(failed, on="log_error")
97113
...
98-
... def bad_action(self):
99-
... raise RuntimeError("something went wrong")
114+
... def do_process(self):
115+
... raise ValueError("bad data")
116+
...
117+
... def log_error(self, error):
118+
... self.last_error = error
100119

101-
>>> sm = ChartWithEvent()
102-
>>> sm.send("go")
103-
>>> sm.configuration == {sm.error_state}
104-
True
120+
>>> sm = ErrorLogger()
121+
>>> sm.send("process")
122+
>>> str(sm.last_error)
123+
'bad data'
105124

106125
```
107126

127+
128+
### Error in error handler
129+
130+
If the `error.execution` handler itself raises, the engine **ignores** the
131+
second error (logging a warning) to prevent infinite loops. The machine
132+
remains in whatever configuration it reached before the failed handler.
133+
108134
```{note}
109-
If you provide an explicit `id=` parameter, it takes precedence and the naming
110-
convention is not applied.
135+
During `error.execution` processing, errors in transition `on` content
136+
are **not** caught at block level — they propagate to the microstep where
137+
they are silently discarded. This prevents infinite loops when an error
138+
handler's own action raises (e.g., a self-transition
139+
`error_execution = s1.to(s1, on="handler")` where `handler` raises).
140+
Entry/exit blocks still use block-level catching regardless of the
141+
current event.
111142
```
112143

113-
### Accessing error data
114144

115-
The error object is passed as `error` in the keyword arguments to callbacks
116-
on the `error.execution` transition:
145+
(error-handling-cleanup-finalize)=
146+
147+
## Cleanup / finalize pattern
148+
149+
A common need is to run cleanup code after a transition **regardless of
150+
success or failure** — releasing a lock, closing a connection, or clearing
151+
temporary state.
152+
153+
Because errors are caught at the block level, `after_<event>()` callbacks
154+
always run — making them a natural **finalize** hook, similar to Python's
155+
`try/finally`:
117156

118157
```py
119-
>>> from statemachine import State, StateChart
158+
>>> from statemachine import Event, State, StateChart
120159

121-
>>> class ErrorDataChart(StateChart):
122-
... s1 = State("s1", initial=True)
123-
... error_state = State("error_state", final=True)
160+
>>> class ResourceManager(StateChart):
161+
... idle = State(initial=True)
162+
... working = State()
163+
... recovering = State()
164+
...
165+
... start = idle.to(working)
166+
... done = working.to(idle)
167+
... recover = recovering.to(idle)
168+
... error_execution = Event(working.to(recovering), id="error.execution")
169+
...
170+
... def __init__(self, should_fail=False):
171+
... self.should_fail = should_fail
172+
... self.released = False
173+
... super().__init__()
124174
...
125-
... go = s1.to(s1, on="bad_action")
126-
... error_execution = s1.to(error_state, on="handle_error")
175+
... def on_enter_working(self):
176+
... if self.should_fail:
177+
... raise RuntimeError("something went wrong")
178+
... self.raise_("done")
127179
...
128-
... def bad_action(self):
129-
... raise RuntimeError("specific error")
180+
... def after_start(self):
181+
... self.released = True # always runs — finalize hook
130182
...
131-
... def handle_error(self, error=None, **kwargs):
183+
... def on_enter_recovering(self, error):
132184
... self.last_error = error
133-
134-
>>> sm = ErrorDataChart()
135-
>>> sm.send("go")
136-
>>> str(sm.last_error)
137-
'specific error'
185+
... self.raise_("recover")
138186

139187
```
140188

189+
On the **success** path, the machine transitions `idle → working → idle`
190+
and `after_start` releases the resource:
141191

142-
### Error-in-error-handler behavior
143-
144-
If an error occurs while processing the `error.execution` event itself, the
145-
engine ignores the second error (logging a warning) to prevent infinite loops.
146-
The state machine remains in the configuration it was in before the failed
147-
error handler.
148-
149-
150-
## Block-level error catching
192+
```py
193+
>>> sm = ResourceManager(should_fail=False)
194+
>>> sm.send("start")
195+
>>> "idle" in sm.configuration_values
196+
True
197+
>>> sm.released
198+
True
151199

152-
`StateChart` catches errors at the **block level**, not the microstep level.
153-
Each phase of the microstep — `on_exit`, transition `on` content, `on_enter`
154-
— is an independent block. An error in one block:
200+
```
155201

156-
- **Stops remaining actions in that block** (per SCXML spec, execution MUST
157-
NOT continue within the same block after an error).
158-
- **Does not affect other blocks** — subsequent phases of the microstep still
159-
execute. In particular, `after` callbacks always run regardless of errors
160-
in earlier blocks.
202+
On the **failure** path, the action raises, but `after_start` **still runs**.
203+
Then `error.execution` fires, transitions to `recovering`, and auto-recovers
204+
back to `idle`:
161205

162-
This means that even if a transition's `on` action raises an exception, the
163-
transition completes: target states are entered and `after_<event>()` callbacks
164-
still run. The error is caught and queued as an `error.execution` internal
165-
event, which can be handled by a separate transition.
206+
```py
207+
>>> sm = ResourceManager(should_fail=True)
208+
>>> sm.send("start")
209+
>>> "idle" in sm.configuration_values
210+
True
211+
>>> sm.released # finalize ran despite the error
212+
True
213+
>>> str(sm.last_error)
214+
'something went wrong'
166215

167-
```{note}
168-
During `error.execution` processing, errors in transition `on` content are
169-
**not** caught at block level — they propagate to the microstep, where they
170-
are silently ignored. This prevents infinite loops when an error handler's own
171-
action raises (e.g., a self-transition `error_execution = s1.to(s1, on="handler")`
172-
where `handler` raises). Entry/exit blocks always use block-level error
173-
catching regardless of the current event.
174216
```
175217

218+
```{seealso}
219+
See {ref}`sphx_glr_auto_examples_statechart_cleanup_machine.py` for a
220+
more detailed version of this pattern with annotated output.
221+
```
176222

177-
(error-handling-cleanup-finalize)=
178-
## Cleanup / finalize pattern
179223

180-
A common need is to run cleanup code after a transition **regardless of
181-
success or failure** — for example, releasing a lock or closing a resource.
224+
## Validators do not trigger error events
182225

183-
Because `StateChart` catches errors at the block level (see above),
184-
`after_<event>()` callbacks still run even when an action raises an exception.
185-
This makes `after_<event>()` a natural **finalize** hook — no need to
186-
duplicate cleanup logic in an error handler.
226+
{ref}`Validators <validators>` operate in the **transition-selection** phase,
227+
before any state changes occur. Their exceptions **always propagate** to the
228+
caller — they are never caught by the engine and never converted to
229+
`error.execution` events, regardless of the `error_on_execution` setting.
187230

188-
For error-specific handling (logging, recovery), define an `error.execution`
189-
transition and use {func}`raise_() <StateChart.raise_>` to auto-recover
190-
within the same macrostep.
231+
This is intentional: a validator rejection means the transition should not
232+
happen at all. It is semantically equivalent to a condition returning
233+
`False`, but communicates the reason via an exception.
191234

192-
See the full working example in {ref}`sphx_glr_auto_examples_statechart_cleanup_machine.py`.
235+
```{seealso}
236+
See {ref}`validators` for examples and the full semantics of validator
237+
propagation.
238+
```
193239

194240

195241
```{seealso}
196-
See {ref}`behaviour` for the full comparison of `StateChart` behavioral
197-
attributes and how to customize them.
242+
See {ref}`behaviour` for the full comparison of behavioral attributes
243+
and how to customize `error_on_execution` and other settings.
244+
See {ref}`actions` for the callback execution order within each
245+
microstep.
198246
```

0 commit comments

Comments
 (0)