Internals¶
Advanced architecture, for a reader who is about to depend on the runtime's behaviour rather than just use it. The subject is small enough to fit on one page because the runtime is deliberately small: one application object, one provider registry, one lifecycle, and one request boundary.
The lifecycle is a state machine, and you can watch it¶
"""Walkthrough — the lifecycle as a state machine, observed from inside it.
Run it with:
python -m learn_production.internals01_lifecycle
"""
from __future__ import annotations
import asyncio
from typing import ClassVar
from xyberos import App
class Recorder:
"""A resource that records the lifecycle state it saw, at both ends of its life.
The states are read from inside a provider factory and inside a close hook, which are the only
two places a caller can observe ``starting`` and ``stopping``: both are transient states that no
caller of ``start()`` or ``stop()`` ever sees from the outside.
"""
def __init__(self, name: str, app: App, events: list[str]) -> None:
self._name = name
self._app = app
self._events = events
self._events.append(f"{name} created while {app.state.value}")
async def aclose(self) -> None:
"""Record the close, and with it the state a shutdown hook runs in."""
self._events.append(f"{self._name} closed while {self._app.state.value}")
class First(Recorder):
"""The first registered resource, so that closing order has something to reverse."""
class Second(Recorder):
"""The second registered resource, and therefore the first to be closed."""
class Extra:
"""A contract registered after validation, to show what that does to the state machine."""
marker: ClassVar[str] = "registered late"
async def scenario() -> None:
"""Walk the application through its states, printing each one as it is reached."""
app = App()
events: list[str] = []
print("after construction:", app.state.value)
app.provide(First, lambda: First("first", app, events), eager=True)
app.provide(Second, lambda: Second("second", app, events), eager=True)
print("after registering:", app.state.value)
plan = app.validate()
print("after validate:", app.state.value)
names = [
getattr(provider.contract, "__qualname__", repr(provider.contract)) for provider in plan
]
print("dependency-safe order:", names)
# Registration is what the validated plan is a statement about, so registering invalidates it
# rather than leaving a stale plan behind. Startup revalidates.
app.provide(Extra, instance=Extra())
print("after registering late:", app.state.value)
await app.start()
print("after start:", app.state.value)
# Starting an application that is already ready is a no-op, and so is stopping one that is
# already stopped: both are safe to call from a shutdown hook that may run twice.
await app.start()
print("after starting twice:", app.state.value)
await app.stop()
print("after stop:", app.state.value)
await app.stop()
print("after stopping twice:", app.state.value)
print("what the resources saw, in order:")
for index, event in enumerate(events, start=1):
print(f" {index}. {event}")
def main() -> None:
"""Run the scenario."""
asyncio.run(scenario())
if __name__ == "__main__":
main()
after construction: new
after registering: new
after validate: validated
dependency-safe order: ['First', 'Second']
after registering late: new
after start: ready
after starting twice: ready
after stop: stopped
after stopping twice: stopped
what the resources saw, in order:
1. first created while starting
2. second created while starting
3. second closed while stopping
4. first closed while stopping
| State | Reached by | What is allowed |
|---|---|---|
new |
App() |
register providers, routes, plugins |
validated |
App.validate() |
the same, plus the graph is known good until something changes |
starting |
App.start() |
nothing from outside; resources are being constructed |
ready |
the end of App.start() |
resolve, serve requests read-only against the registry |
stopping |
App.stop() |
nothing from outside; resources are being closed |
stopped |
the end of App.stop() |
nothing — the object is finished |
failed |
a failed App.start() |
nothing; the failure is the state's whole meaning |
Four behaviours follow from that table, and each is visible in the transcript:
- Registration invalidates validation.
after registering late: new— the validated plan is a statement about the registry as it was, so a new registration drops back tonewand startup revalidates. There is no stale plan to be surprised by. startingandstoppingare observable only from inside. The two states are read here from a provider factory and from a close hook respectively; no caller ofstart()orstop()ever sees them, which is why they are safe to be transient.startandstopare idempotent where it matters. Starting areadyapplication and stopping astoppedone are no-ops, so a shutdown path that may run twice does not have to be careful about it.Appis not restartable. The end of the observability transcript isLifecycleStateError: cannot start an application in 'stopped' state. A process that needs to serve again is a new process; the alternative — quietly reusing closed resources — is the bug this refusal prevents.
Dependency-safe order, and reverse close order¶
after validate: dependency-safe order: ['First', 'Second'] and the four resource events say the same
thing from two sides: construction follows the dependency graph, closing reverses construction order.
Plugins are stopped before app-owned resources are closed, so a plugin's stop can still use a provider
it registered.
The rule matters when resources depend on each other. A connection pool that a client wraps must be
closed after the client, and reversing construction order is how that happens without a configuration
flag. A close hook is found by name (aclose, then close) or supplied at registration with close=;
either way, every hook is attempted and the failures are collected (troubleshooting
shows a ShutdownError naming its resource).
What a request actually does¶
correlation id → error mapping → request scope → your middleware → router → handler
│
└── the boundary everything resolved for this request is
created in and released at the end of
Inside that boundary:
- The handler signature is inspected once, at registration. The adapter compiles each route into a
callable that resolves its annotated parameters and passes them in, so a handler takes ordinary
arguments and the framework answers where they come from (
Annotated[T, Inject()]). - Resolution checks scope in order: app, then request, then transient. An app-scoped provider returns the instance constructed at startup; a request-scoped one is memoized for this request; a transient one is constructed per injection.
- The request's context is a
RequestContext, and it owns cleanup. Everything request-scoped is closed when the request's response is complete, which is what makes "request-scoped" a lifecycle promise rather than a naming convention. - The response is converted by whoever produced it. A handler may return a dict (JSON), a Starlette response, or a response-like object; the adapter passes through what it does not recognize.
- Errors are mapped by the pipeline, not by the handler. An
HttpErrorsubclass carries a status code and a safe message; everything else is logged with its traceback and answered generically (security).
Diagnostics are a view of the registry¶
app.diagnostics() returns provider records (contract, scope, eager, source) and plugin records (name,
version, state, requirements). It is a pure read — it constructs nothing and mutates nothing — which is
why it can be called before startup, while serving, and after shutdown. xyberos check prints the same
records plus the route table (deploying it).
For a test that needs to replace one answer without touching the application, with_overrides returns an
independent clone: the original's registry and routes are copied, the named contracts are replaced with
instances, and the clone can be started and stopped on its own. Nothing about the original changes, which
is the property the registry page is built around.
The design rules, in one list¶
These are the constraints the runtime holds itself to. Each one has a cost that shows up somewhere else on this site, and each is recorded as a decision rather than a preference:
| Rule | Consequence |
|---|---|
| The core imports nothing it does not need | capabilities live behind extras; an unused driver is not installed (security) |
| Configuration is read once, at composition | no os.environ lookups scattered through request handling |
| The provider graph is frozen at startup | diagnostics are reproducible, and a mistake is a failed start |
| Nothing is discovered implicitly | no entry-point plugin discovery, no route auto-importing, no implicit migration |
Every request has an owner: a RequestContext |
request-scoped resources cannot outlive their request |
| Shutdown collects failures instead of raising the first | one bad hook cannot hide the rest |
| Errors are classified at the boundary | clients get mapped messages, operators get everything else |
| Nothing is scheduled by the framework | no background thread, no timer, no metrics loop to reason about |
The decisions themselves, with the alternatives that were rejected, are in the runtime reference, the architecture guide, and the ADR list — including ADR 0001 on runtime boundaries, which is where the "core imports nothing optional" rule is argued.
Common mistakes¶
- Holding a
RequestContextbeyond its request. It is closed when the request ends; the dependencies it owned are not usable afterwards. - Depending on
starting/stoppingfrom outside a factory or a close hook. They are transient by design; usereadyandstopped, and useapp.stateif you need to know where a process is. - Calling
stop()and then continuing to serve. The object is finished by contract; the refusal to restart exists so this is aLifecycleStateErrorrather than an empty connection pool. - Reading diagnostics as a snapshot of instances. It lists registrations and sources, not what has been constructed; use it to answer "who registered this", not "is this alive".
- Assuming plugin order is alphabetical. It is dependency order, and it is reported.