Troubleshooting¶
Two rules make most failures in this runtime easy to read: every failure has a name that says what it is, and almost every failure is raised where the mistake was made — at registration, at validation, or at startup, rather than on the request that happens to arrive second.
The line that identifies it¶
"""Walkthrough — the failures a deployment sees, named, and the one line that identifies each.
Run it with:
python -m learn_production.troubleshooting01_errors
"""
from __future__ import annotations
import asyncio
from typing import Annotated
from xyberos import App, Inject, Scope
from xyberos.errors import XyberosError
class Clock:
"""A contract with no provider, so the graph cannot be satisfied."""
class Tick:
"""A contract used to build a cycle."""
def __init__(self, tock: Annotated[Tock, Inject()]) -> None: # noqa: F821
self.tock = tock
class Tock:
"""The other half of that cycle."""
def __init__(self, tick: Annotated[Tick, Inject()]) -> None:
self.tick = tick
class Counter:
"""A request-scoped contract, so a longer-lived service must not depend on it."""
class Reporter:
"""An application-scoped service that reaches for a request-scoped one."""
def __init__(self, counter: Annotated[Counter, Inject()]) -> None:
self.counter = counter
class Handle:
"""A resource the container is expected to close, which is what transient scope forbids."""
async def aclose(self) -> None:
"""Nothing to release; the presence of this method is the point."""
class Broken:
"""A resource whose shutdown fails, so the failure is reportable rather than fatal."""
async def aclose(self) -> None:
"""Fail the way a socket that is already gone does."""
raise OSError("connection reset by peer")
def report(label: str, action: object) -> None:
"""Print what a failure is called and what it says."""
try:
action() # type: ignore[operator]
except XyberosError as error:
print(f"{label}: {type(error).__name__}: {error}")
async def areport(label: str, awaitable: object) -> None:
"""The same, for the failures only an awaitable can raise."""
try:
await awaitable # type: ignore[misc]
except XyberosError as error:
print(f"{label}: {type(error).__name__}: {error}")
async def scenario() -> None:
"""Trigger each failure by doing the thing that causes it."""
# A registration whose factory cannot produce the contract it claims. The class is checked at
# registration time, so a mistake here never reaches a request.
report("factory returns the wrong type", lambda: App().provide(Clock, Counter))
# A dependency with no provider: reported when the graph is validated, naming the path.
graph = App()
graph.provide(Tick)
report("missing provider", graph.validate)
# A cycle: the same validation, with the path that closes the loop.
cyclic = App()
cyclic.provide(Tick)
cyclic.provide(Tock)
report("dependency cycle", cyclic.validate)
# A scope violation: an app-scoped service outliving a request-scoped dependency.
scoped = App()
scoped.provide(Counter, scope=Scope.REQUEST)
scoped.provide(Reporter)
report("scope violation", scoped.validate)
# Request scope resolved outside a request: legal to register, illegal to resolve.
outside = App()
outside.provide(Counter, scope=Scope.REQUEST)
async with outside.lifespan():
await areport("request scope with no request", outside.resolve(Counter))
# A transient provider returning something the container would have to close. Transient
# instances are never tracked, so this is refused rather than leaked.
transient = App()
transient.provide(Handle, scope=Scope.TRANSIENT)
async with transient.lifespan():
await areport("transient managed resource", transient.resolve(Handle))
# Registration and startup are one-way transitions.
sealed = App()
async with sealed.lifespan():
report("register after start", lambda: sealed.provide(Clock, Counter))
await areport("start after stop", sealed.start())
# Shutdown failures are collected after every resource has been closed, so one broken resource
# never hides the rest, and `.resources` names them. The close hook is found by name, which is
# why this resource needs no registration ceremony beyond existing.
closing = App()
closing.provide(Broken)
try:
async with closing.lifespan():
await closing.resolve(Broken)
except XyberosError as error:
print(f"failed shutdown: {type(error).__name__}: {error}")
print("resources that failed:", getattr(error, "resources", ()))
def main() -> None:
"""Run the scenario."""
asyncio.run(scenario())
if __name__ == "__main__":
main()
factory returns the wrong type: RegistrationError: provider factory Counter returns Counter, which is not compatible with Clock
missing provider: MissingProviderError: no provider is registered for Tock; required by Tick
dependency cycle: DependencyCycleError: dependency cycle detected: Tick -> Tock -> Tick
scope violation: ScopeViolationError: app-scoped Reporter cannot depend on shorter-lived request-scoped Counter
request scope with no request: DependencyResolutionError: request-scoped Counter requires an active request context
transient managed resource: DependencyResolutionError: transient Handle returned a managed resource; use request or app scope instead
register after start: LifecycleStateError: cannot register providers in 'ready' application state
start after stop: LifecycleStateError: cannot start an application in 'stopped' state
failed shutdown: ShutdownError: 1 shutdown problem(s): Broken
resources that failed: ('Broken',)
Ten lines, nine distinct mistakes, and each one names the thing that is wrong: the contract (Clock),
the path (Tick -> Tock -> Tick), the scopes on both ends (app-scoped Reporter … request-scoped
Counter), the state ('ready', 'stopped'), and for shutdown the resource rather than the
exception.
The taxonomy¶
Every error the framework raises derives from XyberosError, and the intermediate classes are the ones
worth catching:
| Group | Base | Members |
|---|---|---|
| configuration | ConfigurationError |
a file that cannot be read, an unknown key, a value of the wrong type |
| registration | RegistrationError |
a factory that cannot produce its contract, two registrations without replace=True, an unhashable contract, a registration after startup |
| dependency graph | DependencyError |
MissingProviderError, DependencyCycleError, ScopeViolationError, DependencyResolutionError — the first three carry the path that identifies where in the graph it failed |
| lifecycle | LifecycleError |
LifecycleStateError for an operation that is invalid in the current state, ShutdownError with failures and resources for a shutdown that had problems |
| HTTP | HttpError |
the only errors whose message reaches a client; RequestValidationError adds an errors tuple, and status_code is what the adapter returns |
| database | DatabaseError |
DatabaseConfigurationError, DatabaseConnectionError, TransactionError, TransientDatabaseError |
| plugins | PluginError |
always carries plugin and phase, and never echoes a configuration value |
| AI | AIError |
the provider-layer taxonomy: transport, timeout, rate limit, invalid payload, model not found, unsupported capability, refusal, schema |
| durable | DurableError |
journal, lease, conflict, version, payload, and outcome-unknown |
| workflow | WorkflowError |
validation, execution, retry exhausted, step and workflow timeout, cancellation |
Two of those groups are markers rather than errors in their own right. TransientError means a retry
policy may retry this, and nothing else is retried, so a programming error is never silently repeated;
AITransportError derives from both AIError and TransientError for exactly that reason. The full
list, with the attributes each class carries, is the reference's core runtime
page and its troubleshooting
checklists.
What to do about each¶
| What you see | What it usually is | Where to look |
|---|---|---|
no provider is registered for X; required by Y |
a dependency declared in a signature with no app.provide, or a module never imported |
the path in the message is the chain; app.diagnostics() lists what is registered |
dependency cycle detected: A -> B -> A |
two services each taking the other | break it by moving one dependency to a method argument, or by injecting a factory |
app-scoped X cannot depend on request-scoped Y |
a long-lived service reaching for a shorter-lived one | make the dependent service request-scoped, or pass the request-scoped value as an argument |
request-scoped X requires an active request context |
resolving request scope from a startup hook, a CLI, or a test outside a request | app.request_context() is the boundary; resolve inside it |
provider factory F returns T, which is not compatible with C |
the contract and the thing produced disagree | the message names both; Annotated[C, Inject()] on the parameter is what declares the contract |
transient X returned a managed resource; use request or app scope instead |
a transient provider returning something with a close/aclose |
transient instances are never tracked, so they are refused rather than leaked |
cannot register providers in 'ready' application state |
a provide/route/add_plugin after start() |
registration is a build-time act; startup freezes it |
cannot start an application in 'stopped' state |
restarting the same App object |
start a new process; an App is not reusable after stop() |
ShutdownError: N shutdown problem(s): … |
a close hook raised | .resources names each one, and every other resource was still closed |
503 from your readiness route |
the database is unreachable while the process is up | the tolerant-provider posture in deploying it |
a 500 whose body says nothing |
an exception that is not an HttpError |
the log has the message and the traceback; security shows both halves |
a 410-style body you did not expect |
an HttpError subclass in your own code |
the type in the body is the class name, so it is greppable |
The rules behind the messages¶
- Validate, then construct.
App.validate()walks the whole graph and reports the first problem with its path.app.start()calls it, so a graph mistake is a failed start rather than a failed request.xyberos checkruns the same code without starting anything (deploying it). - Fail at registration when the information is there. A factory whose return annotation is incompatible with its contract is refused when it is registered, not when it is first needed.
- Keep the cause.
DependencyResolutionErrorraised while constructing a provider keeps the original exception as__cause__, which is whydeploy04_readinesscan reportcaused by DatabaseConnectionErrorin one line. - Collect shutdown failures instead of raising the first. One broken resource cannot hide the rest; the summary carries all of them.
- Never rewrite diagnose-only detail into the response.
HttpErrormessages are for clients; every other message is for the log. The two halves of that rule are one transcript, in security. - Attribute plugin failures. Every
PluginErrorcarriespluginandphase, so a failure is always attributable to a plugin and to the part of its lifecycle it happened in.
When the message is not enough¶
Three escalation steps, in order:
xyberos check --app <target> --app-dir <dir>— the graph, the plugin order, the route signatures, and the configuration that was loaded, without starting a process.app.diagnostics()— the same records from inside a running process, with thesourceof every provider (observability).- The reference troubleshooting guide — per-area checklists for the runtime, HTTP, database, plugins, workflow, AI, and durable execution, including the warnings that mean a feature is being used below the tier it is documented at.
Common mistakes¶
- Catching
Exceptionaroundapp.start()and continuing. A failed start leaves the application infailed; nothing after it will work, and the state says so (internals). - Reading
ShutdownErroras "the application stopped badly". It means the resources were all given a chance to close and some refused;.resourcesis the list to fix. - Adding
replace=Trueto silence a duplicate registration. Duplicate registrations are refused on purpose: the second one is usually the mistake, andsourceexists so the intentional override is visible in diagnostics. - Treating
TransientErroras "retry this" without reading the operation. Only operations that are idempotent are safe to retry, which is why the database helper takes an operation rather than a connection.