Skip to content

Warmup and health

FrozenContainer reads a validated plan two more ways: warmup() builds every singleton before the first request, and health() runs the checks a binding declared. Neither alters the plan; both are optional operations a deployment wires in where it needs them.

Warmup

A singleton is normally built on first resolution. warmup() builds every singleton immediately, in resolution order, so a provider that fails to construct fails at startup — where a deployment can roll back — instead of on the first request that needs it.

>>> from depin import Container, Scope
>>> class Config:
...     def __init__(self) -> None:
...         print('building Config')
>>> class Service:
...     def __init__(self, config: Config) -> None:
...         print('building Service')
...         self.config = config
>>> class Session:
...     def __init__(self) -> None:
...         print('building Session')
>>> di = Container().bind(Config).bind(Service).bind(Session, scope=Scope.SCOPED).freeze()
>>> report = di.warmup()
building Config
building Service
>>> [node.key.__qualname__ for node in report.constructed]
['Config', 'Service']

Session is scoped, and warmup never printed building Session: a scoped value belongs to a scope that has not opened yet, and a transient value is never cached, so neither has a boot-time instance to build. Only singletons are touched.

WarmupReport carries two tuples, both in resolution order and both holding the same GraphNode the graph view already exposes: constructed is what this call built, cached is what was already built. Calling warmup() again builds nothing — everything reports under cached instead:

>>> report = di.warmup()
>>> report.constructed
()
>>> [node.key.__qualname__ for node in report.cached]
['Config', 'Service']

The async rule

A singleton that needs async resolution cannot be built by warmup(). Rather than build every sync singleton and stop partway through the async one, warmup() checks every singleton it would touch first and refuses before constructing anything:

>>> class Pool: ...
>>> async def make_pool() -> Pool:
...     return Pool()
>>> di = Container().bind(make_pool, provides=Pool).freeze()
>>> di.warmup()
Traceback (most recent call last):
    ...
depin.errors.AsyncInSyncContextError: warmup() cannot construct Pool: it requires async resolution. Call awarmup() instead.

awarmup() is the counterpart an ASGI lifespan calls; it drives async and sync singletons alike and returns the same report.

A failure aborts startup

A singleton whose construction raises propagates the exception unchanged. Warmup does not catch it and does not report a partial result — a container with some singletons built and one failed is a startup to abort, not a state to report:

>>> class Broken:
...     def __init__(self) -> None:
...         raise RuntimeError('cannot reach the database')
>>> di = Container().bind(Broken).freeze()
>>> di.warmup()
Traceback (most recent call last):
    ...
RuntimeError: cannot reach the database

Health checks

bind(Database, check=ping) declares how to verify the value that binding produces. The check is not run at bind time, or by freeze(), or by warmup() — only health() and ahealth() run it, and only when asked.

>>> from depin import Container
>>> class Database:
...     def __init__(self) -> None:
...         self.connected = True
>>> def ping(db: Database) -> bool:
...     return db.connected
>>> di = Container().bind(Database, check=ping).freeze()
>>> report = di.health()
>>> report.healthy
True
>>> report.results[0].key.__qualname__, report.results[0].error
('Database', None)

A check receives the value its provider produced — ping above is called with the Database instance, not with the class. It is healthy unless it raises or returns exactly False; a 0 or an empty string is a value the check returned, not a verdict, so it does not count as a failure.

checks() returns the declared checks as data, in resolution order, and runs nothing:

>>> [check.key.__qualname__ for check in di.checks()]
['Database']

Every check runs

One check failing never stops another from running. health() calls every declared check and collects every result, even after the first one fails:

>>> class Cache:
...     def __init__(self) -> None:
...         self.up = False
>>> def cache_ping(cache: Cache) -> bool:
...     return cache.up
>>> di = Container().bind(Database, check=ping).bind(Cache, check=cache_ping).freeze()
>>> report = di.health()
>>> report.healthy
False
>>> [(result.key.__qualname__, result.healthy) for result in report.results]
[('Database', True), ('Cache', False)]

A check that raises is caught, and the exception is carried on HealthResult.error rather than propagating:

>>> def failing_ping(db: Database) -> bool:
...     raise ConnectionError('connection refused')
>>> di = Container().bind(Database, check=failing_ping).freeze()
>>> result = di.health().results[0]
>>> result.healthy, result.error
(False, ConnectionError('connection refused'))

The async rule

A check on an async provider, or an async def check itself, needs an event loop. health() checks every declared check first and refuses before running any of them, the same rule warmup() applies to construction:

>>> async def make_pool() -> Pool:
...     return Pool()
>>> async def aping(pool: Pool) -> bool:
...     return True
>>> di = Container().bind(make_pool, provides=Pool, check=aping).freeze()
>>> di.health()
Traceback (most recent call last):
    ...
depin.errors.AsyncInSyncContextError: health() cannot run the check for Pool: it requires an event loop, because the provider is async or the check is. Call ahealth() instead.

ahealth() awaits both kinds.

A resolution error propagates; a check's own error does not

health() distinguishes failing to build the value from the value failing its check. A provider that raises while resolving is a container misused, and that exception propagates like any other resolution failure — it is never turned into a HealthResult:

>>> class Unreachable:
...     def __init__(self) -> None:
...         raise RuntimeError('no route to host')
>>> def unreachable_check(value: object) -> bool:
...     return True
>>> di = Container().bind(Unreachable, check=unreachable_check).freeze()
>>> di.health()
Traceback (most recent call last):
    ...
RuntimeError: no route to host

Only once the value exists does its check run, and only a failure from that call is reported on HealthResult.

A check on a scoped binding needs a scope

Running a check resolves its provider exactly as any other resolution would. A check on a Scope.SCOPED binding therefore needs an active scope, and raises OutsideScopeError without one:

>>> from depin import Scope
>>> from depin.errors import OutsideScopeError
>>> class Session:
...     def __init__(self) -> None:
...         self.ok = True
>>> def session_check(session: Session) -> bool:
...     return session.ok
>>> di = Container().bind(Session, scope=Scope.SCOPED, check=session_check).freeze()
>>> try:
...     di.health()
... except OutsideScopeError as exc:
...     print(type(exc).__name__)
OutsideScopeError
>>> with di.scope():
...     di.health().healthy
True

A check on a decorated binding verifies the undecorated value

A check stays with the binding it was declared on. decorate() moves the registered binding to Underlying(key, 0) and puts the wrapper on the public key, and a check declared on the registered binding rides with it — it verifies the value before decoration, not after, and HealthCheck.key names the underlying key:

>>> from depin import Underlying
>>> class Store:
...     def __init__(self) -> None:
...         self.ok = True
>>> def store_check(store: Store) -> bool:
...     return store.ok
>>> class Logged:
...     def __init__(self, inner: Store) -> None:
...         self.inner = inner
>>> di = Container().bind(Store, check=store_check).decorate(Store, Logged).freeze()
>>> di.checks()[0].key == Underlying(Store, 0)
True

decorate() takes no check= parameter: a decorator has no check of its own, only the binding it wraps can declare one.