|
2 | 2 | (error-handling)= |
3 | 3 | # Error handling |
4 | 4 |
|
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 | +``` |
8 | 9 |
|
9 | | -## Two models |
| 10 | +What happens when a callback raises an exception during a transition? |
10 | 11 |
|
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). |
15 | 16 |
|
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 | +``` |
18 | 23 |
|
19 | | -```python |
20 | | -from statemachine import StateChart |
21 | 24 |
|
22 | | -class MyChart(StateChart): |
23 | | - error_on_execution = False # exceptions propagate to the caller |
24 | | -``` |
| 25 | +(error-execution)= |
25 | 26 |
|
26 | | -### When to use which |
| 27 | +## How errors are caught |
27 | 28 |
|
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: |
32 | 32 |
|
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>()` | |
39 | 38 |
|
| 39 | +An error in one block: |
40 | 40 |
|
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. |
43 | 46 |
|
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>`. |
48 | 51 |
|
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. |
51 | 65 |
|
52 | 66 | ### The `error_` naming convention |
53 | 67 |
|
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"`: |
58 | 72 |
|
59 | 73 | ```py |
60 | 74 | >>> from statemachine import State, StateChart |
61 | 75 |
|
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) |
65 | 79 | ... |
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) |
68 | 82 | ... |
69 | | -... def bad_action(self): |
| 83 | +... def risky_action(self): |
70 | 84 | ... raise RuntimeError("something went wrong") |
71 | 85 |
|
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 |
75 | 89 | True |
76 | 90 |
|
77 | 91 | ``` |
78 | 92 |
|
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. |
83 | 96 | ``` |
84 | 97 |
|
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: |
87 | 103 |
|
88 | 104 | ```py |
89 | | ->>> from statemachine import Event, State, StateChart |
| 105 | +>>> from statemachine import State, StateChart |
90 | 106 |
|
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) |
94 | 110 | ... |
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") |
97 | 113 | ... |
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 |
100 | 119 |
|
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' |
105 | 124 |
|
106 | 125 | ``` |
107 | 126 |
|
| 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 | + |
108 | 134 | ```{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. |
111 | 142 | ``` |
112 | 143 |
|
113 | | -### Accessing error data |
114 | 144 |
|
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`: |
117 | 156 |
|
118 | 157 | ```py |
119 | | ->>> from statemachine import State, StateChart |
| 158 | +>>> from statemachine import Event, State, StateChart |
120 | 159 |
|
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__() |
124 | 174 | ... |
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") |
127 | 179 | ... |
128 | | -... def bad_action(self): |
129 | | -... raise RuntimeError("specific error") |
| 180 | +... def after_start(self): |
| 181 | +... self.released = True # always runs — finalize hook |
130 | 182 | ... |
131 | | -... def handle_error(self, error=None, **kwargs): |
| 183 | +... def on_enter_recovering(self, error): |
132 | 184 | ... self.last_error = error |
133 | | - |
134 | | ->>> sm = ErrorDataChart() |
135 | | ->>> sm.send("go") |
136 | | ->>> str(sm.last_error) |
137 | | -'specific error' |
| 185 | +... self.raise_("recover") |
138 | 186 |
|
139 | 187 | ``` |
140 | 188 |
|
| 189 | +On the **success** path, the machine transitions `idle → working → idle` |
| 190 | +and `after_start` releases the resource: |
141 | 191 |
|
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 |
151 | 199 |
|
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 | +``` |
155 | 201 |
|
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`: |
161 | 205 |
|
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' |
166 | 215 |
|
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. |
174 | 216 | ``` |
175 | 217 |
|
| 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 | +``` |
176 | 222 |
|
177 | | -(error-handling-cleanup-finalize)= |
178 | | -## Cleanup / finalize pattern |
179 | 223 |
|
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 |
182 | 225 |
|
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. |
187 | 230 |
|
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. |
191 | 234 |
|
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 | +``` |
193 | 239 |
|
194 | 240 |
|
195 | 241 | ```{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. |
198 | 246 | ``` |
0 commit comments