Skip to content

Commit e02f866

Browse files
authored
feat: add timeout() contrib helper for per-state watchdog timers (#585)
* feat: add `timeout()` contrib helper for per-state watchdog timers Leverages the invoke system to provide state timeouts — a background timer starts on state entry and is auto-cancelled on exit. If the timer expires, a configurable event is sent to the machine. Closes #549 * docs: point timeout feature matrix entry to new timeout page
1 parent f7cbd4a commit e02f866

7 files changed

Lines changed: 339 additions & 1 deletion

File tree

docs/api.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,16 @@ class MyMachine(StateChart):
146146
```{eval-rst}
147147
.. autofunction:: statemachine.io.create_machine_class_from_definition
148148
```
149+
150+
## timeout
151+
152+
```{versionadded} 3.0.0
153+
```
154+
155+
```{seealso}
156+
{ref}`timeout` how-to guide.
157+
```
158+
159+
```{eval-rst}
160+
.. autofunction:: statemachine.contrib.timeout.timeout
161+
```

docs/how-to/coming_from_transitions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -960,4 +960,4 @@ See {ref}`validations` for the full list.
960960
| Ordered transitions | Yes | Via explicit wiring |
961961
| Tags on states | Yes | Via subclassing |
962962
| {ref}`Machine nesting (children) <invoke>` | Yes | Yes (invoke) |
963-
| Timeout transitions | Yes | {ref}`Yes <delayed-events>` |
963+
| {ref}`Timeout transitions <timeout>` | Yes | Yes |

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ invoke
5555
models
5656
integrations
5757
weighted_transitions
58+
timeout
5859
```
5960

6061
```{toctree}

docs/releases/3.0.0.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,31 @@ through the existing condition system — no engine changes required.
532532
See {ref}`weighted-transitions` for full documentation.
533533

534534

535+
#### State timeouts
536+
537+
A new contrib module `statemachine.contrib.timeout` provides a `timeout()` invoke helper
538+
for per-state watchdog timers. When a state is entered, a background timer starts; if the
539+
state is not exited before the timer expires, an event is sent automatically. The timer is
540+
cancelled on state exit, with no manual cleanup needed.
541+
542+
```py
543+
>>> from statemachine import State, StateChart
544+
>>> from statemachine.contrib.timeout import timeout
545+
546+
>>> class WaitingMachine(StateChart):
547+
... waiting = State(initial=True, invoke=timeout(5, on="expired"))
548+
... timed_out = State(final=True)
549+
... expired = waiting.to(timed_out)
550+
551+
>>> sm = WaitingMachine()
552+
>>> sm.waiting.is_active
553+
True
554+
555+
```
556+
557+
See {ref}`timeout` for full documentation.
558+
559+
535560
#### Create state machine from a dict definition
536561

537562
Dynamically create state machine classes using

docs/timeout.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
(timeout)=
2+
# State timeouts
3+
4+
A common need is preventing a state machine from getting stuck — for example,
5+
a "waiting for response" state that should time out after a few seconds. The
6+
{func}`~statemachine.contrib.timeout.timeout` helper makes this easy by
7+
leveraging the {ref}`invoke <invoke>` system: a background timer starts when
8+
the state is entered and is automatically cancelled when the state is exited.
9+
10+
## Basic usage
11+
12+
When the timeout expires and no custom event is specified, the standard
13+
`done.invoke.<state>` event fires — just like any other invoke completion:
14+
15+
```py
16+
>>> from statemachine import State, StateChart
17+
>>> from statemachine.contrib.timeout import timeout
18+
19+
>>> class WaitingMachine(StateChart):
20+
... waiting = State(initial=True, invoke=timeout(5))
21+
... done = State(final=True)
22+
... done_invoke_waiting = waiting.to(done)
23+
24+
>>> sm = WaitingMachine()
25+
>>> sm.waiting.is_active
26+
True
27+
28+
```
29+
30+
In this example, if the machine stays in `waiting` for 5 seconds,
31+
`done.invoke.waiting` fires and the machine transitions to `done`.
32+
If any other event causes a transition out of `waiting` first,
33+
the timer is cancelled automatically.
34+
35+
36+
## Custom timeout event
37+
38+
Use the `on` parameter to send a specific event name instead of
39+
`done.invoke.<state>`. This is useful when you want to distinguish
40+
timeouts from normal completions:
41+
42+
```py
43+
>>> from statemachine import State, StateChart
44+
>>> from statemachine.contrib.timeout import timeout
45+
46+
>>> class RequestMachine(StateChart):
47+
... requesting = State(initial=True, invoke=timeout(30, on="request_timeout"))
48+
... timed_out = State(final=True)
49+
... request_timeout = requesting.to(timed_out)
50+
51+
>>> sm = RequestMachine()
52+
>>> sm.requesting.is_active
53+
True
54+
55+
```
56+
57+
## Composing with other invoke handlers
58+
59+
Since `timeout()` returns a standard invoke handler, you can combine it with
60+
other handlers in a list. The first handler to complete and trigger a transition
61+
wins — the state exit cancels everything else:
62+
63+
```py
64+
>>> from statemachine import State, StateChart
65+
>>> from statemachine.contrib.timeout import timeout
66+
67+
>>> def fetch_data():
68+
... return {"status": "ok"}
69+
70+
>>> class LoadingMachine(StateChart):
71+
... loading = State(initial=True, invoke=[fetch_data, timeout(30, on="too_slow")])
72+
... ready = State(final=True)
73+
... stuck = State(final=True)
74+
... done_invoke_loading = loading.to(ready)
75+
... too_slow = loading.to(stuck)
76+
77+
>>> sm = LoadingMachine()
78+
>>> sm.ready.is_active
79+
True
80+
81+
```
82+
83+
In this example:
84+
- If `fetch_data` completes within 30 seconds, `done.invoke.loading` fires
85+
and transitions to `ready`, cancelling the timeout.
86+
- If 30 seconds pass first, `too_slow` fires and transitions to `stuck`,
87+
cancelling the `fetch_data` invoke.
88+
89+
90+
## API reference
91+
92+
See {func}`~statemachine.contrib.timeout.timeout` in the {ref}`API docs <api>`.

statemachine/contrib/timeout.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Timeout helper for state invocations.
2+
3+
Provides a ``timeout()`` function that returns an :class:`~statemachine.invoke.IInvoke`
4+
handler. When a state is entered, the handler waits for the given duration; if the state
5+
is not exited before the timer expires, an event is sent to the machine.
6+
7+
Example::
8+
9+
from statemachine.contrib.timeout import timeout
10+
11+
class MyMachine(StateChart):
12+
waiting = State(initial=True, invoke=timeout(5, on="expired"))
13+
timed_out = State(final=True)
14+
expired = waiting.to(timed_out)
15+
"""
16+
17+
from typing import TYPE_CHECKING
18+
from typing import Any
19+
20+
if TYPE_CHECKING:
21+
from statemachine.invoke import InvokeContext
22+
23+
24+
class _Timeout:
25+
"""IInvoke handler that waits for a duration and optionally sends an event."""
26+
27+
def __init__(self, duration: float, on: "str | None" = None):
28+
self.duration = duration
29+
self.on = on
30+
31+
def run(self, ctx: "InvokeContext") -> Any:
32+
"""Wait for the timeout duration, then optionally send an event.
33+
34+
If the owning state is exited before the timer expires (``ctx.cancelled``
35+
is set), the handler returns immediately without sending anything.
36+
"""
37+
fired = not ctx.cancelled.wait(timeout=self.duration)
38+
if not fired:
39+
# State was exited before the timeout — nothing to do.
40+
return None
41+
if self.on is not None:
42+
ctx.send(self.on)
43+
return None
44+
45+
def __repr__(self) -> str:
46+
args = f"{self.duration}"
47+
if self.on is not None:
48+
args += f", on={self.on!r}"
49+
return f"timeout({args})"
50+
51+
52+
def timeout(duration: float, *, on: "str | None" = None) -> _Timeout:
53+
"""Create a timeout invoke handler.
54+
55+
Args:
56+
duration: Time in seconds to wait before firing.
57+
on: Event name to send when the timeout expires. If ``None``, the
58+
standard ``done.invoke.<state>`` event fires via invoke completion.
59+
60+
Returns:
61+
An :class:`~statemachine.invoke.IInvoke`-compatible handler.
62+
63+
Raises:
64+
ValueError: If *duration* is not positive.
65+
"""
66+
if duration <= 0:
67+
raise ValueError(f"timeout duration must be positive, got {duration}")
68+
return _Timeout(duration=duration, on=on)

tests/test_contrib_timeout.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for the timeout contrib module."""
2+
3+
import threading
4+
5+
import pytest
6+
from statemachine.contrib.timeout import _Timeout
7+
from statemachine.contrib.timeout import timeout
8+
9+
from statemachine import State
10+
from statemachine import StateChart
11+
12+
13+
class TestTimeoutValidation:
14+
def test_positive_duration(self):
15+
t = timeout(5)
16+
assert isinstance(t, _Timeout)
17+
assert t.duration == 5
18+
19+
def test_zero_duration_raises(self):
20+
with pytest.raises(ValueError, match="must be positive"):
21+
timeout(0)
22+
23+
def test_negative_duration_raises(self):
24+
with pytest.raises(ValueError, match="must be positive"):
25+
timeout(-1)
26+
27+
def test_repr_without_on(self):
28+
assert repr(timeout(5)) == "timeout(5)"
29+
30+
def test_repr_with_on(self):
31+
assert repr(timeout(3.5, on="expired")) == "timeout(3.5, on='expired')"
32+
33+
34+
class TestTimeoutBasic:
35+
"""Timeout fires done.invoke.<state> when no custom event is specified."""
36+
37+
async def test_timeout_fires_done_invoke(self, sm_runner):
38+
class SM(StateChart):
39+
waiting = State(initial=True, invoke=timeout(0.05))
40+
done = State(final=True)
41+
done_invoke_waiting = waiting.to(done)
42+
43+
sm = await sm_runner.start(SM)
44+
await sm_runner.sleep(0.15)
45+
await sm_runner.processing_loop(sm)
46+
47+
assert "done" in sm.configuration_values
48+
49+
async def test_timeout_cancelled_on_early_exit(self, sm_runner):
50+
"""If the machine transitions out before the timeout, nothing fires."""
51+
52+
class SM(StateChart):
53+
waiting = State(initial=True, invoke=timeout(10))
54+
other = State(final=True)
55+
go = waiting.to(other)
56+
# No done_invoke_waiting — would fail if timeout fired unexpectedly
57+
done_invoke_waiting = waiting.to(waiting)
58+
59+
sm = await sm_runner.start(SM)
60+
await sm_runner.send(sm, "go")
61+
62+
assert "other" in sm.configuration_values
63+
64+
65+
class TestTimeoutCustomEvent:
66+
"""Timeout fires a custom event via the `on` parameter."""
67+
68+
async def test_custom_event_fires(self, sm_runner):
69+
class SM(StateChart):
70+
waiting = State(initial=True, invoke=timeout(0.05, on="expired"))
71+
timed_out = State(final=True)
72+
expired = waiting.to(timed_out)
73+
74+
sm = await sm_runner.start(SM)
75+
await sm_runner.sleep(0.15)
76+
await sm_runner.processing_loop(sm)
77+
78+
assert "timed_out" in sm.configuration_values
79+
80+
async def test_custom_event_cancelled_on_early_exit(self, sm_runner):
81+
class SM(StateChart):
82+
waiting = State(initial=True, invoke=timeout(10, on="expired"))
83+
other = State(final=True)
84+
go = waiting.to(other)
85+
expired = waiting.to(waiting)
86+
87+
sm = await sm_runner.start(SM)
88+
await sm_runner.send(sm, "go")
89+
90+
assert "other" in sm.configuration_values
91+
92+
93+
class TestTimeoutComposition:
94+
"""Timeout combined with other invoke handlers — first to complete wins."""
95+
96+
async def test_invoke_completes_before_timeout(self, sm_runner):
97+
"""A fast invoke handler transitions out, cancelling the timeout."""
98+
99+
def fast_handler():
100+
return "fast_result"
101+
102+
class SM(StateChart):
103+
loading = State(initial=True, invoke=[fast_handler, timeout(10, on="too_slow")])
104+
ready = State(final=True)
105+
stuck = State(final=True)
106+
done_invoke_loading = loading.to(ready)
107+
too_slow = loading.to(stuck)
108+
109+
sm = await sm_runner.start(SM)
110+
await sm_runner.sleep(0.15)
111+
await sm_runner.processing_loop(sm)
112+
113+
assert "ready" in sm.configuration_values
114+
115+
async def test_timeout_fires_before_slow_invoke(self, sm_runner):
116+
"""Timeout fires while a slow invoke handler is still running."""
117+
handler_cancelled = threading.Event()
118+
119+
class SlowHandler:
120+
def run(self, ctx):
121+
# Wait until cancelled (state exit) — simulates long-running work
122+
ctx.cancelled.wait()
123+
handler_cancelled.set()
124+
125+
class SM(StateChart):
126+
loading = State(initial=True, invoke=[SlowHandler(), timeout(0.05, on="too_slow")])
127+
ready = State(final=True)
128+
stuck = State(final=True)
129+
done_invoke_loading = loading.to(ready)
130+
too_slow = loading.to(stuck)
131+
132+
sm = await sm_runner.start(SM)
133+
await sm_runner.sleep(0.15)
134+
await sm_runner.processing_loop(sm)
135+
136+
assert "stuck" in sm.configuration_values
137+
# The slow handler should have been cancelled when the state exited
138+
handler_cancelled.wait(timeout=2)
139+
assert handler_cancelled.is_set()

0 commit comments

Comments
 (0)