Litestar integration
XStatePlugin, a Provide() interpreter dependency and a generated statechart Controller for Litestar, reusing the Starlette registry, Receipt → HTTP mapping, Idempotency-Key and SSE/WebSocket streams.
Litestar apps get the same statechart REST surface as FastAPI — same routes, statuses, problem bodies and operationIds; the one difference is exclude_events= instead of shadowing a route: a generated Controller with a typed /send body documented as a discriminated union, one route per declared event, RFC 9457 problems, SSE and WebSocket. It is built on the [starlette] StatechartRegistry — Litestar speaks ASGI, so the registry, the Receipt → HTTP mapping, Idempotency-Key handling and the streams are reused unchanged. Only the edges are adapted: a Litestar request is read through a Starlette view of the same ASGI scope, and a Starlette response is converted to a Litestar Response / Stream.
Install
pip install "xstate-statemachine[litestar]"
Requires Litestar >=2.0 (and installs Starlette >=0.27). Typed EventModel bodies also need [pydantic]. Tested versions are in the compatibility table. litestar.testing.TestClient (used below) needs httpx.
Quick start
from litestar import Litestar
from litestar.testing import TestClient
from xstate_statemachine import create_machine
from xstate_statemachine.contrib.litestar import (
StatechartRegistry,
XStatePlugin,
create_statechart_controller,
)
from xstate_statemachine.persistence import MemoryStore
door = create_machine(
{
"id": "door",
"initial": "closed",
"states": {
"closed": {
"on": {
"OPEN": "open",
},
},
"open": {
"on": {
"CLOSE": "closed",
},
},
},
}
)
def authorize(request, *, name, key, event):
return request.headers.get("x-user") is not None # your auth here
registry = StatechartRegistry(MemoryStore())
registry.register("door", door, authorize=authorize)
app = Litestar(
route_handlers=[
create_statechart_controller(registry, "door", path="/doors")
],
plugins=[XStatePlugin(registry)], # lifespan, probes, problem mapping
)
with TestClient(app) as client:
user = {
"x-user": "ann",
}
r = client.post(
"/doors/1/send",
json={
"type": "OPEN",
},
headers=user,
)
assert r.status_code == 200 and r.json()["state"] == "open"
assert (
client.post(
"/doors/1/send",
json={
"type": "OPNE",
},
headers=user,
).status_code
== 422
)
assert client.get("/doors/1").status_code == 403 # no user
assert client.get("/_xsm/ready").json()["status"] == "ready"
assert "/doors/{id}/send" in app.openapi_schema.paths
Owning an event route
A hand-written handler takes the interpreter as a Provide dependency and answers with ReceiptResponse — the Litestar-native one exported by this package (Starlette’s ReceiptResponse cannot be returned from a Litestar handler; that is a 500).
Litestar refuses two handlers on one path (ImproperlyConfiguredException), so unlike FastAPI an app cannot shadow a generated /events/<EVENT> route by registering it first. Pass exclude_events= instead: the controller skips that route and refuses the event on /send (403) so your route cannot be bypassed. Still gate the event in authorize — the WebSocket route and any second controller go through it; exclude_events covers only this controller.
from typing import Any
from litestar import Litestar, post
from litestar.testing import TestClient
from xstate_statemachine import create_machine
from xstate_statemachine.contrib.litestar import (
ReceiptResponse,
StatechartRegistry,
XStatePlugin,
create_statechart_controller,
get_interpreter,
)
from xstate_statemachine.persistence import MemoryStore
order = create_machine(
{
"id": "order",
"initial": "open",
"states": {
"open": {
"on": {
"PAY": "paid",
},
},
"paid": {},
},
}
)
def authorize(request, *, name, key, event):
# get_interpreter authorizes with event=None; every generated route
# and WebSocket frame passes the event type, so PAY never gets through
return event != "PAY"
registry = StatechartRegistry(MemoryStore())
registry.register("order", order, authorize=authorize)
charged = []
@post(
"/orders/{id:str}/events/PAY",
status_code=200,
dependencies={
"order": get_interpreter(registry, "order"),
},
)
async def pay(order: Any, id: str) -> Any:
receipt = await order.send("PAY", wait=True)
if receipt.changed:
charged.append(id) # real code: an outbox row
return ReceiptResponse(order, receipt)
app = Litestar(
route_handlers=[
pay,
create_statechart_controller(
registry, "order", path="/orders", exclude_events=("PAY",)
),
],
plugins=[XStatePlugin(registry)],
)
with TestClient(app) as client:
assert client.post("/orders/1/events/PAY").json()["state"] == "paid"
assert (
client.post(
"/orders/2/send",
json={
"type": "PAY",
},
).status_code
== 403
)
assert charged == ["1"]
get_interpreter saves when the handler returns and discards when it raises; the FastAPI Request rules (persistence, Idempotency-Key, guards on GET /events, gate on authorize) apply unchanged. guards= and dependencies= attach Litestar guards / Provides to every generated route.
OpenAPI
Served at /schema/openapi.json and deterministic for a given chart and model set (Litestar would otherwise generate random examples for msgspec Struct schemas; the controller pins them).
- operationIds — the same rule (shared code) as FastAPI:
<prefix>_get|send|events|diagram|stream, and<prefix>_<event>folded to ASCII[a-z0-9_]. Plain names (ORDER_PAID) keep the unsuffixed id; folded ones (ORDER.PAID) or names clashing with a fixed route (get) take_2,_3, … — unique, and stable when you add an event. /sendbody — oneEventModel: that model; several:oneOf+discriminator: type(needsXStatePlugin); none:<Name>Event={type: enum[...], payload: {}}with unknown top-level keys rejected. Aliased fields are honoured; actions read them under the alias.- Responses —
200is typed (StateBody,ReceiptBody,EventsBody;contextis filled only with acontext_serializer). Failures areapplication/problem+json(Problem):400 401 403 404 409 413 415 422 501 503on sends,400 401 403 404 503on reads,429on/stream. - 422 problems list
{"key", "source"}per error (an unknown key’s name, never a value), at most 50, pluserrors_total. - Golden. The library pins the AdvancePayment controller’s served document in
tests/contrib/litestar/openapi_golden.json; regenerate withXSM_UPDATE_GOLDEN=1 pytest tests/contrib/litestar -k goldenand review the diff.
msgspec and pydantic
“Works with both” means: without event models the /send body is a msgspec Struct (no pydantic involved); with EventModels ([pydantic]) it is a pydantic RootModel over the union, validated by Litestar’s own PydanticPlugin (auto-registered when pydantic is importable). XStatePlugin is needed only so the document shows the bare union rather than {root: …}. Both produce the same value-free 422 problem.
Testing streams
litestar.testing.TestClient.stream() buffers the response, so a never-ending SSE stream hangs the test. Drive /stream with httpx.AsyncClient(transport=httpx.ASGITransport(app)) inside the app’s lifespan, or a raw ASGI driver that reads the first http.response.body frames.
Reference
create_statechart_controller(registry, name, *, path=None, key_param="id", tags=None, event_models=None, include_diagram=True, create_if_missing=True, operation_id_prefix=None, exclude_events=(), guards=(), dependencies=None) -> type[Controller]
A Controller subclass for machine name with the FastAPI route table: GET /{id}, POST /{id}/send, POST /{id}/events/<EVENT> per declared event, GET /{id}/events, GET /{id}/diagram.mmd, GET /{id}/stream (SSE) and WS /{id}/ws. Status mapping, Idempotency-Key and authorize are the registry’s (send_event). The /send body is a Pydantic RootModel over the discriminated union of the machine’s EventModels (documented in OpenAPI as oneOf + discriminator by XStatePlugin), or a msgspec Struct {type: Literal[<declared>], payload: {...}} when there are none. Validation failures, non-JSON bodies (415) and oversize bodies (413) are problem responses. exclude_events= drops those events’ routes and refuses them on /send (unknown names are a ValueError); guards= / dependencies= apply to every route.
XStatePlugin(registry, *, dependencies=False, key_param="id", dependency_prefix="", health_path="/_xsm/health", ready_path="/_xsm/ready")
An InitPluginProtocol + OpenAPISchemaPluginProtocol. On app init it appends registry.lifespan to the app’s lifespan, mounts the health/ready probes (pass None to skip), maps every XStateMachineError escaping a handler to a problem response (a save conflict → 409) and, with dependencies=True, registers one app-level dependency per machine named {dependency_prefix}{name}.
get_interpreter(registry, name, *, key="id", create_if_missing=True) -> Provide
Yields a started interpreter inside registry.act() — saved with expected_version when the handler returns, discarded when it raises. Runs authorize with event=None; the idempotency principal is the registry’s principal(conn). key is a path-parameter name or (request) -> str.
Re-exports
StatechartRegistry, allow_all, receipt_to_status, problem, problem_for_exception — the same objects as in xstate_statemachine.contrib.starlette. ReceiptResponse(interp, receipt, *, context_serializer=None, status=None) is Litestar-native (same body and status mapping as Starlette’s).
Guarantees
What this does: the same multi-worker model as Starlette: create → act → persist → discard with an optimistic version check — racing requests give one winner and a
409, never a lost update (tested with 50 concurrentPOST /sendto one key onSQLiteStore). The OpenAPI document lists every declared event. SSE subscribers see onetransitionper committed changed receipt. The registry’s lifespan (timer scanner, resident drain) runs under Litestar’s.What this does not do: SSE/WebSocket fan-out and residents are per-process. Nothing retries a
409for you. The controller is generated from the chart at startup.See the programme-wide Guarantees and Security pages (#303).
Threat model
Who can call this: anyone who can reach the controller.
register(authorize=)is required (X0.1) and runs on every route, per instance and per event; add Litestar guards on the controller for framework-native checks.What it exposes:
GETreturns state, state ids and available events — notcontextunless you pass acontext_serializer(X0.1). Problems carry a fixed title and the exception class name; validation problems list field keys only, never input values (X0.7).You must configure: an
authorize=callable, and a registryprincipal=derived from your authenticated identity when usinginbox=—Idempotency-Keyis scoped to it (X0.2). Bodies are JSON-only and size-capped. CSRF: with cookie authentication, add Litestar’sCSRFConfigorSameSitecookies; a cross-site form cannot sendapplication/jsonwithout a CORS preflight, but do not rely on that alone.
Compatibility
| Litestar | Python | Tested in CI |
|---|---|---|
>=2.0 declared; 2.14 – latest tested (2.0 does not import on Python 3.9; 2.1 – 2.13 emit OpenAPI whose per-event path parameters do not validate) |
3.9 and 3.13 cells | ✅ |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
MissingExtraError: … pip install "xstate-statemachine[litestar]" |
extra not installed | run the command |
/send body is {type, payload} |
no events_union() models on the machine |
pass event_schemas=events_union(...) or event_models= |
OpenAPI shows {root: ...} for /send |
XStatePlugin not registered |
add plugins=[XStatePlugin(registry)] |
500 from a Provide route |
returned Starlette’s ReceiptResponse |
import ReceiptResponse from contrib.litestar |
ImproperlyConfiguredException naming an /events/<EVENT> path |
your route and the controller’s share it | exclude_events=("<EVENT>",) |
403 on /send for one event |
the event is in exclude_events |
send it on your dedicated route |
OpenAPI examples differ per start |
a msgspec Struct of your own in a response |
ResponseSpec(generate_examples=False) or explicit examples= |
| SSE test hangs | TestClient.stream buffers |
see Testing streams |
LitestarDeprecationWarning: Inferred dependency field |
Litestar ≥ 2.18 on a get_interpreter parameter |
annotate the parameter with NamedDependency[Any] on new Litestar |