Skip to content

API reference: adapters

Every adapter lives behind an extra. Importing one without its extra raises an ImportError naming what to install.

adapters.builtin

Zero-dependency entrypoints, also re-exported from the top-level package.

First-party zero-dependency entrypoint adapters (no extra required).

DaemonEntrypoint

Bases: ScopedEntrypoint

Runs func(scope, stop) in one long-lived unit scope.

The function is expected to loop until stop is set.

Source code in servicewright/adapters/builtin/daemon.py
class DaemonEntrypoint(ScopedEntrypoint):
    """Runs ``func(scope, stop)`` in one long-lived unit scope.

    The function is expected to loop until ``stop`` is set.
    """

    def __init__(
        self,
        func: Callable[[UnitScopeProtocol, asyncio.Event], Awaitable[None]],
        *,
        kind: str = "daemon",
        essential: bool = True,
    ) -> None:
        super().__init__()
        self._func = func
        self.kind = kind
        self.essential = essential

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Open one unit scope and hand control to the user loop."""
        async with self.unit_scope() as scope:
            await self._func(scope, stop)

bind(ctx) async

Capture the container so per-unit scopes can be opened.

Source code in servicewright/core/contracts/bases.py
async def bind(self, ctx: ServiceContext) -> None:
    """Capture the container so per-unit scopes can be opened."""
    self._container = ctx.container

drain(grace) async

Stop intake; let in-flight units finish.

Source code in servicewright/core/contracts/bases.py
async def drain(self, grace: float) -> None:
    """Stop intake; let in-flight units finish."""
    return None

serve(*, stop) async

Open one unit scope and hand control to the user loop.

Source code in servicewright/adapters/builtin/daemon.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Open one unit scope and hand control to the user loop."""
    async with self.unit_scope() as scope:
        await self._func(scope, stop)

stop() async

Hard stop / release resources.

Source code in servicewright/core/contracts/bases.py
async def stop(self) -> None:
    """Hard stop / release resources."""
    return None

unit_scope(context=None)

Open a per-unit-of-work DI scope.

Raises:

Type Description
RuntimeError

If called before :meth:bind.

Source code in servicewright/core/contracts/bases.py
def unit_scope(
    self, context: Mapping[Any, Any] | None = None
) -> contextlib.AbstractAsyncContextManager[UnitScopeProtocol]:
    """Open a per-unit-of-work DI scope.

    Raises:
        RuntimeError: If called before :meth:`bind`.
    """
    if self._container is None:
        raise RuntimeError("unit_scope() called before bind(); entrypoint is not bound to a container")
    return self._container.unit_scope(context)

OneShotEntrypoint

Bases: ScopedEntrypoint

Runs func exactly once inside a fresh unit scope, then returns.

Being essential by default, its return stops the whole service.

Source code in servicewright/adapters/builtin/oneshot.py
class OneShotEntrypoint(ScopedEntrypoint):
    """Runs ``func`` exactly once inside a fresh unit scope, then returns.

    Being ``essential`` by default, its return stops the whole service.
    """

    def __init__(
        self,
        func: Callable[[UnitScopeProtocol], Awaitable[None]],
        *,
        kind: str = "oneshot",
        essential: bool = True,
    ) -> None:
        super().__init__()
        self._func = func
        self.kind = kind
        self.essential = essential

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Open a unit scope, run the function once, then return."""
        async with self.unit_scope() as scope:
            await self._func(scope)

bind(ctx) async

Capture the container so per-unit scopes can be opened.

Source code in servicewright/core/contracts/bases.py
async def bind(self, ctx: ServiceContext) -> None:
    """Capture the container so per-unit scopes can be opened."""
    self._container = ctx.container

drain(grace) async

Stop intake; let in-flight units finish.

Source code in servicewright/core/contracts/bases.py
async def drain(self, grace: float) -> None:
    """Stop intake; let in-flight units finish."""
    return None

serve(*, stop) async

Open a unit scope, run the function once, then return.

Source code in servicewright/adapters/builtin/oneshot.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Open a unit scope, run the function once, then return."""
    async with self.unit_scope() as scope:
        await self._func(scope)

stop() async

Hard stop / release resources.

Source code in servicewright/core/contracts/bases.py
async def stop(self) -> None:
    """Hard stop / release resources."""
    return None

unit_scope(context=None)

Open a per-unit-of-work DI scope.

Raises:

Type Description
RuntimeError

If called before :meth:bind.

Source code in servicewright/core/contracts/bases.py
def unit_scope(
    self, context: Mapping[Any, Any] | None = None
) -> contextlib.AbstractAsyncContextManager[UnitScopeProtocol]:
    """Open a per-unit-of-work DI scope.

    Raises:
        RuntimeError: If called before :meth:`bind`.
    """
    if self._container is None:
        raise RuntimeError("unit_scope() called before bind(); entrypoint is not bound to a container")
    return self._container.unit_scope(context)

adapters.fastapi

FastAPI entrypoint adapter ([fastapi] extra).

CORSMiddlewareConfig dataclass

Configuration for Starlette's CORSMiddleware.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class CORSMiddlewareConfig:
    """Configuration for Starlette's ``CORSMiddleware``."""

    enabled: bool = True
    allow_origins: list[str] = field(default_factory=list)
    allow_credentials: bool = False
    allow_methods: list[str] = field(default_factory=lambda: list(DEFAULT_CORS_METHODS))
    allow_headers: list[str] = field(default_factory=lambda: ["*"])
    expose_headers: list[str] = field(default_factory=list)
    max_age: int = 600

    def __post_init__(self) -> None:
        """Reject the insecure ``allow_credentials`` + wildcard-origin combo."""
        if self.allow_credentials and "*" in self.allow_origins:
            raise ValueError(
                "allow_credentials=True cannot be used with allow_origins=['*']. Specify explicit origins for security."
            )

__post_init__()

Reject the insecure allow_credentials + wildcard-origin combo.

Source code in servicewright/adapters/fastapi/config.py
def __post_init__(self) -> None:
    """Reject the insecure ``allow_credentials`` + wildcard-origin combo."""
    if self.allow_credentials and "*" in self.allow_origins:
        raise ValueError(
            "allow_credentials=True cannot be used with allow_origins=['*']. Specify explicit origins for security."
        )

CorrelationIdMiddlewareConfig dataclass

How the request id is returned to the client.

The id itself is owned by :class:ContextMiddleware — the same value that lands in the context store, the logs and outbound propagation. Echoing it is what lets a caller quote an id that actually appears in your logs.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class CorrelationIdMiddlewareConfig:
    """How the request id is returned to the client.

    The id itself is owned by :class:`ContextMiddleware` — the same value that
    lands in the context store, the logs and outbound propagation. Echoing it
    is what lets a caller quote an id that actually appears in your logs.
    """

    enabled: bool = True
    header_name: str = DEFAULT_CORRELATION_ID_HEADER

FastApiEntrypoint

Bases: ServerEntrypoint

A FastAPI server entrypoint driven by the :class:Host.

Parameters:

Name Type Description Default
config HttpConfig | None

Self-contained server configuration (NOT read from settings).

None
routers tuple[Any, ...]

APIRouter instances to include_router onto the app.

()
routes_registerer RoutesRegisterer | None

Optional callback to register routes imperatively, resolved at bind time with the :class:ServiceContext.

None
middlewares MiddlewareConfig | None

Middleware stack configuration.

None
exception_handlers dict[type[Exception], ExceptionHandler] | None

Extra {exc_type: handler} mappings appended after the default handlers.

None
default_exception_handlers bool

Install the default handlers (validation/HTTP/ServiceError/deadline/unhandled). Default True.

True
error_renderer HttpErrorRendererProtocol | None

Wire-format renderer used by the default handlers; None = RFC 9457 ProblemDetailsRenderer. Pass your own implementation to own the error format (custom envelope, localization) across every default handler.

None
metrics bool

Expose in-app Prometheus metrics at /system/metrics via prometheus-fastapi-instrumentator (requires servicewright[fastapi]).

False
configure_app ConfigureApp | None

Final hook called with (app, ctx) after wiring.

None
kind str

Telemetry label (default "http").

'http'
essential bool

Whether the entrypoint's exit/failure stops the process.

True
Source code in servicewright/adapters/fastapi/entrypoint.py
class FastApiEntrypoint(ServerEntrypoint):
    """A FastAPI server entrypoint driven by the :class:`Host`.

    Args:
        config: Self-contained server configuration (NOT read from settings).
        routers: APIRouter instances to ``include_router`` onto the app.
        routes_registerer: Optional callback to register routes imperatively,
            resolved at ``bind`` time with the :class:`ServiceContext`.
        middlewares: Middleware stack configuration.
        exception_handlers: Extra ``{exc_type: handler}`` mappings appended after
            the default handlers.
        default_exception_handlers: Install the default handlers
            (validation/HTTP/ServiceError/deadline/unhandled). Default ``True``.
        error_renderer: Wire-format renderer used by the default handlers;
            ``None`` = RFC 9457 ``ProblemDetailsRenderer``. Pass your own
            implementation to own the error format (custom envelope,
            localization) across every default handler.
        metrics: Expose in-app Prometheus metrics at ``/system/metrics`` via
            ``prometheus-fastapi-instrumentator`` (requires servicewright[fastapi]).
        configure_app: Final hook called with ``(app, ctx)`` after wiring.
        kind: Telemetry label (default ``"http"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        config: HttpConfig | None = None,
        routers: tuple[Any, ...] = (),
        routes_registerer: RoutesRegisterer | None = None,
        middlewares: MiddlewareConfig | None = None,
        exception_handlers: dict[type[Exception], ExceptionHandler] | None = None,
        default_exception_handlers: bool = True,
        error_renderer: HttpErrorRendererProtocol | None = None,
        metrics: bool = False,
        configure_app: ConfigureApp | None = None,
        kind: str = "http",
        essential: bool = True,
    ) -> None:
        self._config = config if config is not None else HttpConfig()
        self._routers = tuple(routers)
        self._routes_registerer = routes_registerer
        self._middlewares = middlewares if middlewares is not None else MiddlewareConfig()
        self._exception_handlers: dict[type[Exception], ExceptionHandler] = dict(exception_handlers or {})
        self._default_exception_handlers = default_exception_handlers
        self._error_renderer = error_renderer
        self._metrics = metrics
        self._configure_app = configure_app
        self.kind = kind
        self.essential = essential

        self._app: FastAPI | None = None
        self._runner = UvicornRunner(
            host=self._config.host,
            port=self._config.port,
            graceful_timeout=self._config.graceful_timeout,
            uvicorn_kwargs=self._config.uvicorn_kwargs,
            label="FastAPI",
        )

    @property
    def config(self) -> HttpConfig:
        """The server configuration."""
        return self._config

    @property
    def app(self) -> FastAPI | None:
        """The built FastAPI application (``None`` before :meth:`bind`)."""
        return self._app

    @property
    def bound_port(self) -> int | None:
        """The actually bound port (useful when ``config.port == 0``)."""
        return self._runner.bound_port

    async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
        """Build the FastAPI app and open the listening socket.

        The socket is opened here, not in :meth:`serve`, so a port clash aborts
        startup with an ``OSError`` while readiness is still false — instead of
        a process that reports ready and serves nothing. It also makes
        ``port=0`` usable: :attr:`bound_port` reports what the OS picked.
        """
        self._app = await self.build_app(ctx)
        bound_port = self._runner.bind()
        logger.info(
            "FastAPI entrypoint bound",
            extra={"service": ctx.service_name, "address": self._config.address, "port": bound_port},
        )

    async def build_app(self, ctx: ServiceContext[Any, Any]) -> FastAPI:
        """Construct a fully-configured FastAPI app from the :class:`ServiceContext`.

        Exposed for testability: callers can build the app without driving the
        serve loop. The app deliberately has NO container-managing lifespan —
        the Host owns the application scope.
        """
        app = self._create_app(ctx)

        if self._config.health.enabled:
            setup_health_routes(
                app,
                ctx.health,
                liveness_path=self._config.health.liveness_path,
                readiness_path=self._config.health.readiness_path,
            )

        setup_exception_handlers(
            app,
            setup_defaults=self._default_exception_handlers,
            error_renderer=self._error_renderer,
            custom_handlers=self._exception_handlers,
        )

        # OTel must instrument the app BEFORE middleware so it wraps the stack.
        instrument_fastapi_app(app, ctx, middlewares=self._middlewares, health=self._config.health)

        setup_middleware_stack(app, self._middlewares, ctx.container, error_renderer=self._error_renderer)

        for router in self._routers:
            app.include_router(router)

        await self._register_routes(app, ctx)

        if self._metrics:
            setup_metrics_instrumentator(app)

        if self._configure_app is not None:
            self._configure_app(app, ctx)

        return app

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Serve on the bound socket until the host's ``stop`` event is set.

        Returns while the server is still accepting connections: the Host flips
        readiness to false first and only then calls :meth:`drain`, which is
        what closes the listener. Shutting uvicorn down here instead would make
        the readiness endpoint die before the load balancer stops routing, and
        would render the Host's drain grace meaningless.

        A server that dies on its own (a fatal uvicorn error) ends the wait and
        the failure is re-raised, so an essential entrypoint cannot leave the
        process alive and idle.
        """
        if self._app is None:
            raise RuntimeError("serve() called before bind()")
        await self._runner.serve(self._app, stop=stop)

    async def drain(self, grace: float) -> None:
        """Close the listener and let in-flight requests finish within ``grace``."""
        await self._runner.drain(grace)

    async def stop(self) -> None:
        """Hard stop the server immediately (idempotent with :meth:`drain`)."""
        await self._runner.stop()

    def _create_app(self, ctx: ServiceContext[Any, Any]) -> FastAPI:
        params: dict[str, Any] = {
            "title": self._config.title or ctx.service_name,
            "version": self._config.version,
            "openapi_url": self._config.openapi_url,
            "docs_url": self._config.docs_url,
            "redoc_url": self._config.redoc_url,
            "redirect_slashes": self._config.redirect_slashes,
        }
        params.update(self._config.fastapi_kwargs)
        return FastAPI(**params)

    async def _register_routes(self, app: FastAPI, ctx: ServiceContext[Any, Any]) -> None:
        if self._routes_registerer is None:
            return
        result = self._routes_registerer(app, ctx)
        if asyncio.iscoroutine(result):
            await result

app property

The built FastAPI application (None before :meth:bind).

bound_port property

The actually bound port (useful when config.port == 0).

config property

The server configuration.

bind(ctx) async

Build the FastAPI app and open the listening socket.

The socket is opened here, not in :meth:serve, so a port clash aborts startup with an OSError while readiness is still false — instead of a process that reports ready and serves nothing. It also makes port=0 usable: :attr:bound_port reports what the OS picked.

Source code in servicewright/adapters/fastapi/entrypoint.py
async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
    """Build the FastAPI app and open the listening socket.

    The socket is opened here, not in :meth:`serve`, so a port clash aborts
    startup with an ``OSError`` while readiness is still false — instead of
    a process that reports ready and serves nothing. It also makes
    ``port=0`` usable: :attr:`bound_port` reports what the OS picked.
    """
    self._app = await self.build_app(ctx)
    bound_port = self._runner.bind()
    logger.info(
        "FastAPI entrypoint bound",
        extra={"service": ctx.service_name, "address": self._config.address, "port": bound_port},
    )

build_app(ctx) async

Construct a fully-configured FastAPI app from the :class:ServiceContext.

Exposed for testability: callers can build the app without driving the serve loop. The app deliberately has NO container-managing lifespan — the Host owns the application scope.

Source code in servicewright/adapters/fastapi/entrypoint.py
async def build_app(self, ctx: ServiceContext[Any, Any]) -> FastAPI:
    """Construct a fully-configured FastAPI app from the :class:`ServiceContext`.

    Exposed for testability: callers can build the app without driving the
    serve loop. The app deliberately has NO container-managing lifespan —
    the Host owns the application scope.
    """
    app = self._create_app(ctx)

    if self._config.health.enabled:
        setup_health_routes(
            app,
            ctx.health,
            liveness_path=self._config.health.liveness_path,
            readiness_path=self._config.health.readiness_path,
        )

    setup_exception_handlers(
        app,
        setup_defaults=self._default_exception_handlers,
        error_renderer=self._error_renderer,
        custom_handlers=self._exception_handlers,
    )

    # OTel must instrument the app BEFORE middleware so it wraps the stack.
    instrument_fastapi_app(app, ctx, middlewares=self._middlewares, health=self._config.health)

    setup_middleware_stack(app, self._middlewares, ctx.container, error_renderer=self._error_renderer)

    for router in self._routers:
        app.include_router(router)

    await self._register_routes(app, ctx)

    if self._metrics:
        setup_metrics_instrumentator(app)

    if self._configure_app is not None:
        self._configure_app(app, ctx)

    return app

drain(grace) async

Close the listener and let in-flight requests finish within grace.

Source code in servicewright/adapters/fastapi/entrypoint.py
async def drain(self, grace: float) -> None:
    """Close the listener and let in-flight requests finish within ``grace``."""
    await self._runner.drain(grace)

serve(*, stop) async

Serve on the bound socket until the host's stop event is set.

Returns while the server is still accepting connections: the Host flips readiness to false first and only then calls :meth:drain, which is what closes the listener. Shutting uvicorn down here instead would make the readiness endpoint die before the load balancer stops routing, and would render the Host's drain grace meaningless.

A server that dies on its own (a fatal uvicorn error) ends the wait and the failure is re-raised, so an essential entrypoint cannot leave the process alive and idle.

Source code in servicewright/adapters/fastapi/entrypoint.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Serve on the bound socket until the host's ``stop`` event is set.

    Returns while the server is still accepting connections: the Host flips
    readiness to false first and only then calls :meth:`drain`, which is
    what closes the listener. Shutting uvicorn down here instead would make
    the readiness endpoint die before the load balancer stops routing, and
    would render the Host's drain grace meaningless.

    A server that dies on its own (a fatal uvicorn error) ends the wait and
    the failure is re-raised, so an essential entrypoint cannot leave the
    process alive and idle.
    """
    if self._app is None:
        raise RuntimeError("serve() called before bind()")
    await self._runner.serve(self._app, stop=stop)

stop() async

Hard stop the server immediately (idempotent with :meth:drain).

Source code in servicewright/adapters/fastapi/entrypoint.py
async def stop(self) -> None:
    """Hard stop the server immediately (idempotent with :meth:`drain`)."""
    await self._runner.stop()

FastApiPlugin

Declarative wiring: register a :class:FastApiEntrypoint on the host.

Pass the same arguments as :class:FastApiEntrypoint; on_register builds it and adds it to the host.

Source code in servicewright/adapters/fastapi/entrypoint.py
class FastApiPlugin:
    """Declarative wiring: register a :class:`FastApiEntrypoint` on the host.

    Pass the same arguments as :class:`FastApiEntrypoint`; ``on_register`` builds
    it and adds it to the host.
    """

    def __init__(
        self,
        *,
        config: HttpConfig | None = None,
        routers: tuple[Any, ...] = (),
        routes_registerer: RoutesRegisterer | None = None,
        middlewares: MiddlewareConfig | None = None,
        exception_handlers: dict[type[Exception], ExceptionHandler] | None = None,
        default_exception_handlers: bool = True,
        error_renderer: HttpErrorRendererProtocol | None = None,
        metrics: bool = False,
        configure_app: ConfigureApp | None = None,
        kind: str = "http",
        essential: bool = True,
    ) -> None:
        self._entrypoint = FastApiEntrypoint(
            config=config,
            routers=routers,
            routes_registerer=routes_registerer,
            middlewares=middlewares,
            exception_handlers=exception_handlers,
            default_exception_handlers=default_exception_handlers,
            error_renderer=error_renderer,
            metrics=metrics,
            configure_app=configure_app,
            kind=kind,
            essential=essential,
        )

    @property
    def entrypoint(self) -> FastApiEntrypoint:
        """The entrypoint that will be registered on the host."""
        return self._entrypoint

    def on_register(self, spec: Any, host: Any) -> None:
        """Append the FastAPI entrypoint to the host."""
        host.add_entrypoint(self._entrypoint)

entrypoint property

The entrypoint that will be registered on the host.

on_register(spec, host)

Append the FastAPI entrypoint to the host.

Source code in servicewright/adapters/fastapi/entrypoint.py
def on_register(self, spec: Any, host: Any) -> None:
    """Append the FastAPI entrypoint to the host."""
    host.add_entrypoint(self._entrypoint)

GZipMiddlewareConfig dataclass

Configuration for Starlette's GZipMiddleware.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class GZipMiddlewareConfig:
    """Configuration for Starlette's ``GZipMiddleware``."""

    enabled: bool = True
    minimum_size: int = 1000

HealthConfig dataclass

Configuration for the /system health probe routes.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class HealthConfig:
    """Configuration for the ``/system`` health probe routes."""

    enabled: bool = True
    liveness_path: str = DEFAULT_LIVENESS_PATH
    readiness_path: str = DEFAULT_READINESS_PATH

HttpConfig dataclass

Self-contained configuration for an HTTP server entrypoint.

Taken at construction, never read from global settings, so the AppSpec stays transport-neutral.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class HttpConfig:
    """Self-contained configuration for an HTTP server entrypoint.

    Taken at construction, never read from global settings, so the AppSpec
    stays transport-neutral.
    """

    host: str = DEFAULT_HTTP_HOST
    port: int = DEFAULT_HTTP_PORT
    graceful_timeout: float = DEFAULT_GRACEFUL_TIMEOUT_SECONDS

    # FastAPI app construction.
    title: str | None = None
    version: str = "0.0.0"
    openapi_url: str = DEFAULT_OPENAPI_URL
    docs_url: str = DEFAULT_DOCS_URL
    redoc_url: str = DEFAULT_REDOC_URL
    redirect_slashes: bool = False
    fastapi_kwargs: dict[str, Any] = field(default_factory=dict)

    # uvicorn server construction.
    uvicorn_kwargs: dict[str, Any] = field(default_factory=dict)

    # Components.
    health: HealthConfig = field(default_factory=HealthConfig)

    @property
    def address(self) -> str:
        """Return the ``host:port`` bind address."""
        return f"{self.host}:{self.port}"

address property

Return the host:port bind address.

LivenessResponse

Bases: BaseModel

Liveness probe body.

Source code in servicewright/adapters/fastapi/schemas.py
class LivenessResponse(BaseModel):
    """Liveness probe body."""

    model_config = ConfigDict(extra="forbid")

    status: Literal["ok"]

LoggingMiddlewareConfig dataclass

Configuration for the request-logging middleware.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class LoggingMiddlewareConfig:
    """Configuration for the request-logging middleware."""

    enabled: bool = True
    ignored_paths: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORED_PATHS))

MetricsInstrumentatorConfig dataclass

Configuration for prometheus-fastapi-instrumentator.

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class MetricsInstrumentatorConfig:
    """Configuration for ``prometheus-fastapi-instrumentator``."""

    init_kwargs: dict[str, Any] = field(default_factory=dict)
    instrument_kwargs: dict[str, Any] = field(default_factory=dict)
    expose_kwargs: dict[str, Any] = field(default_factory=dict)

MiddlewareConfig dataclass

Configuration for the standard platform middleware stack.

The simple booleans toggle the parameter-less platform middlewares; the complex configs carry their own options. custom lets callers append extra ASGI middleware classes (added first so they run last in the stack).

Source code in servicewright/adapters/fastapi/config.py
@dataclass(slots=True)
class MiddlewareConfig:
    """Configuration for the standard platform middleware stack.

    The simple booleans toggle the parameter-less platform middlewares; the
    complex configs carry their own options. ``custom`` lets callers append
    extra ASGI middleware classes (added first so they run last in the stack).
    """

    # Simple toggle-only middlewares.
    unit_scope: bool = True
    """Open one ``UnitScope`` per request (``UnitScopeMiddleware``, outermost).

    Set ``False`` when the framework's own DI integration already owns the
    request scope — dishka's ``setup_dishka`` for instance — so the two never
    open two scopes per request. ``UnitScopeDep`` / ``current_unit_scope()``
    then raise ``LookupError``; resolve through that integration instead.
    """
    context: bool = True
    sentry: bool = True
    processing_time: bool = True

    # Pluggable context propagation: ``None`` = the defaults (structlog +
    # OTel baggage when opentelemetry is installed); pass your own
    # ContextSetter list to replace them.
    context_setters: list[Any] | None = None

    # Complex middlewares.
    logging: LoggingMiddlewareConfig = field(default_factory=LoggingMiddlewareConfig)
    correlation_id: CorrelationIdMiddlewareConfig = field(default_factory=CorrelationIdMiddlewareConfig)
    gzip: GZipMiddlewareConfig = field(default_factory=GZipMiddlewareConfig)
    cors: CORSMiddlewareConfig = field(default_factory=CORSMiddlewareConfig)

    # Custom middlewares: (middleware_class, kwargs).
    custom: list[tuple[type, dict[str, Any]]] = field(default_factory=list)

unit_scope = True class-attribute instance-attribute

Open one UnitScope per request (UnitScopeMiddleware, outermost).

Set False when the framework's own DI integration already owns the request scope — dishka's setup_dishka for instance — so the two never open two scopes per request. UnitScopeDep / current_unit_scope() then raise LookupError; resolve through that integration instead.

OtelBaggageSetter

Bases: ContextSetter

Put request/user/trace identifiers into OpenTelemetry Baggage.

Baggage rides the W3C baggage header, so OTel-instrumented clients (httpx, grpc, kafka) propagate the values to downstream services with no custom code. Filter propagators on clients calling third parties if these identifiers must not leave your system.

Raises:

Type Description
ImportError

If opentelemetry-api is not installed.

Source code in servicewright/adapters/fastapi/context.py
class OtelBaggageSetter(ContextSetter):
    """Put request/user/trace identifiers into OpenTelemetry Baggage.

    Baggage rides the W3C ``baggage`` header, so OTel-instrumented clients
    (httpx, grpc, kafka) propagate the values to downstream services with no
    custom code. Filter propagators on clients calling third parties if these
    identifiers must not leave your system.

    Raises:
        ImportError: If ``opentelemetry-api`` is not installed.
    """

    def __init__(self) -> None:
        if not OTEL_AVAILABLE:
            raise ImportError(_OTEL_HINT)

    def set(self, context_data: dict[str, Any]) -> Callable[[], None]:
        """Attach one OTel context carrying the values; return the detacher."""
        ctx = _otel_context.get_current()
        dirty = False
        for key in _CONTEXT_KEYS:
            if value := context_data.get(key):
                ctx = _otel_baggage.set_baggage(key, str(value), context=ctx)
                dirty = True

        token = _otel_context.attach(ctx) if dirty else None
        return OtelBaggageRemover(token)

set(context_data)

Attach one OTel context carrying the values; return the detacher.

Source code in servicewright/adapters/fastapi/context.py
def set(self, context_data: dict[str, Any]) -> Callable[[], None]:
    """Attach one OTel context carrying the values; return the detacher."""
    ctx = _otel_context.get_current()
    dirty = False
    for key in _CONTEXT_KEYS:
        if value := context_data.get(key):
            ctx = _otel_baggage.set_baggage(key, str(value), context=ctx)
            dirty = True

    token = _otel_context.attach(ctx) if dirty else None
    return OtelBaggageRemover(token)

ProblemDetails

Bases: BaseModel

RFC 9457 Problem Details document (application/problem+json).

The shape produced by the default :class:~servicewright.core.errors.ProblemDetailsRenderer; extra members are allowed per the RFC (custom renderers may add their own extensions).

Source code in servicewright/adapters/fastapi/schemas.py
class ProblemDetails(BaseModel):
    """RFC 9457 Problem Details document (``application/problem+json``).

    The shape produced by the default
    :class:`~servicewright.core.errors.ProblemDetailsRenderer`; extra members
    are allowed per the RFC (custom renderers may add their own extensions).
    """

    model_config = ConfigDict(extra="allow")

    type: str = "about:blank"
    title: str
    status: int
    detail: str | None = None
    code: str | None = None
    params: dict[str, Any] | None = Field(default=None)

ReadinessResponse

Bases: BaseModel

Readiness probe body.

Source code in servicewright/adapters/fastapi/schemas.py
class ReadinessResponse(BaseModel):
    """Readiness probe body."""

    model_config = ConfigDict(extra="forbid")

    status: Literal["ok", "unhealthy"]

StructlogSetter

Bases: ContextSetter

Bind request/user/trace identifiers into structlog contextvars.

Raises:

Type Description
ImportError

If structlog is not installed.

Source code in servicewright/adapters/fastapi/context.py
class StructlogSetter(ContextSetter):
    """Bind request/user/trace identifiers into ``structlog`` contextvars.

    Raises:
        ImportError: If ``structlog`` is not installed.
    """

    def __init__(self) -> None:
        if not STRUCTLOG_AVAILABLE:
            raise ImportError(_STRUCTLOG_HINT)

    def set(self, context_data: dict[str, Any]) -> Callable[[], None]:
        """Bind the contextvars and return a remover."""
        bind_data: dict[str, Any] = {}
        bound_keys: list[str] = []
        for key in _CONTEXT_KEYS:
            if value := context_data.get(key):
                bind_data[key] = value
                bound_keys.append(key)

        if bind_data:
            structlog.contextvars.bind_contextvars(**bind_data)

        return StructlogRemover(bound_keys)

set(context_data)

Bind the contextvars and return a remover.

Source code in servicewright/adapters/fastapi/context.py
def set(self, context_data: dict[str, Any]) -> Callable[[], None]:
    """Bind the contextvars and return a remover."""
    bind_data: dict[str, Any] = {}
    bound_keys: list[str] = []
    for key in _CONTEXT_KEYS:
        if value := context_data.get(key):
            bind_data[key] = value
            bound_keys.append(key)

    if bind_data:
        structlog.contextvars.bind_contextvars(**bind_data)

    return StructlogRemover(bound_keys)

UnitScopeMiddleware

Open one UnitScope per request and expose it three ways.

Installed by the :class:FastApiEntrypoint as the outermost wrapper around the handler unless MiddlewareConfig.unit_scope is off. The scope carries the Request as its context and stays open until the response is fully delivered.

This is deliberately a raw ASGI middleware rather than a BaseHTTPMiddleware: the latter hands control back as soon as the response starts, so the scope — and with it every REQUEST-scoped dependency, e.g. a database session — would be finalized while a streaming body is still being produced and before BackgroundTasks run, truncating responses that the client already received a 200 for. Awaiting the inner app to completion keeps the scope alive for the whole exchange, which is also what the Litestar adapter does.

Source code in servicewright/adapters/fastapi/unit_scope.py
class UnitScopeMiddleware:
    """Open one ``UnitScope`` per request and expose it three ways.

    Installed by the :class:`FastApiEntrypoint` as the outermost wrapper around
    the handler unless ``MiddlewareConfig.unit_scope`` is off. The scope
    carries the ``Request`` as its ``context`` and stays open until the
    response is fully delivered.

    This is deliberately a raw ASGI middleware rather than a
    ``BaseHTTPMiddleware``: the latter hands control back as soon as the
    response *starts*, so the scope — and with it every REQUEST-scoped
    dependency, e.g. a database session — would be finalized while a streaming
    body is still being produced and before ``BackgroundTask``s run, truncating
    responses that the client already received a 200 for. Awaiting the inner app
    to completion keeps the scope alive for the whole exchange, which is also
    what the Litestar adapter does.
    """

    def __init__(self, app: ASGIApp, container: DependencyContainerProtocol) -> None:
        self._app = app
        self._container = container

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        """Wrap the request in a fresh unit scope bound to request.state + a contextvar."""
        if scope["type"] != "http":
            await self._app(scope, receive, send)
            return

        request = Request(scope)
        async with self._container.unit_scope({"request": request}) as unit_scope:
            request.state.unit_scope = unit_scope
            token = _current_unit_scope.set(unit_scope)
            try:
                await self._app(scope, receive, send)
            finally:
                _current_unit_scope.reset(token)

__call__(scope, receive, send) async

Wrap the request in a fresh unit scope bound to request.state + a contextvar.

Source code in servicewright/adapters/fastapi/unit_scope.py
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
    """Wrap the request in a fresh unit scope bound to request.state + a contextvar."""
    if scope["type"] != "http":
        await self._app(scope, receive, send)
        return

    request = Request(scope)
    async with self._container.unit_scope({"request": request}) as unit_scope:
        request.state.unit_scope = unit_scope
        token = _current_unit_scope.set(unit_scope)
        try:
            await self._app(scope, receive, send)
        finally:
            _current_unit_scope.reset(token)

current_unit_scope()

Return the :class:UnitScopeProtocol for the in-flight HTTP request.

Raises:

Type Description
LookupError

If called outside a request handled by :class:UnitScopeMiddleware.

Source code in servicewright/adapters/fastapi/unit_scope.py
def current_unit_scope() -> UnitScopeProtocol:
    """Return the :class:`UnitScopeProtocol` for the in-flight HTTP request.

    Raises:
        LookupError: If called outside a request handled by
            :class:`UnitScopeMiddleware`.
    """
    try:
        return _current_unit_scope.get()
    except LookupError as exc:
        raise LookupError(
            "No active HTTP unit scope; current_unit_scope() must be called inside a request "
            "served by a FastApiEntrypoint with UnitScopeMiddleware installed "
            "(MiddlewareConfig.unit_scope=True, the default)."
        ) from exc

get_default_context_setters()

Return the default context setters.

Both are SOFT capabilities, activated by what is installed: the structlog setter (log correlation out of the box) and the OTel Baggage setter (so the identifiers propagate to downstream services via instrumented clients). Both ship with servicewright[observability]; without them the request context still lands in the transport-neutral store.

Source code in servicewright/adapters/fastapi/context.py
def get_default_context_setters() -> list[ContextSetter]:
    """Return the default context setters.

    Both are SOFT capabilities, activated by what is installed: the structlog
    setter (log correlation out of the box) and the OTel Baggage setter (so the
    identifiers propagate to downstream services via instrumented clients).
    Both ship with ``servicewright[observability]``; without them the request
    context still lands in the transport-neutral store.
    """
    setters: list[ContextSetter] = []
    if OTEL_AVAILABLE:
        setters.append(OtelBaggageSetter())
    if STRUCTLOG_AVAILABLE:
        setters.append(StructlogSetter())
    return setters

get_unit_scope(request)

FastAPI dependency returning the per-request unit scope.

Resolves from request.state (set by :class:UnitScopeMiddleware).

Example

from typing import Annotated from fastapi import Depends async def handler(scope: Annotated[UnitScopeProtocol, Depends(get_unit_scope)]): ... use_case = await scope.get(MyUseCase)

Source code in servicewright/adapters/fastapi/unit_scope.py
def get_unit_scope(request: Request) -> UnitScopeProtocol:
    """FastAPI dependency returning the per-request unit scope.

    Resolves from ``request.state`` (set by :class:`UnitScopeMiddleware`).

    Example:
        >>> from typing import Annotated
        >>> from fastapi import Depends
        >>> async def handler(scope: Annotated[UnitScopeProtocol, Depends(get_unit_scope)]):
        ...     use_case = await scope.get(MyUseCase)
    """
    scope: UnitScopeProtocol | None = getattr(request.state, "unit_scope", None)
    if scope is None:
        raise LookupError(
            "No active HTTP unit scope on request.state; the FastApiEntrypoint UnitScopeMiddleware "
            "must be installed (MiddlewareConfig.unit_scope=True, the default) for get_unit_scope() to resolve."
        )
    return scope

setup_default_exception_handlers(app, *, renderer=None)

Register the default exception handlers on app.

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application.

required
renderer HttpErrorRendererProtocol | None

The wire-format renderer; defaults to RFC 9457 :class:~servicewright.core.errors.ProblemDetailsRenderer.

None
Source code in servicewright/adapters/fastapi/exceptions.py
def setup_default_exception_handlers(app: FastAPI, *, renderer: HttpErrorRendererProtocol | None = None) -> None:
    """Register the default exception handlers on ``app``.

    Args:
        app: The FastAPI application.
        renderer: The wire-format renderer; defaults to RFC 9457
            :class:`~servicewright.core.errors.ProblemDetailsRenderer`.
    """
    active_renderer: HttpErrorRendererProtocol = renderer if renderer is not None else ProblemDetailsRenderer()

    def _render(info: ErrorInfo) -> JSONResponse:
        rendered = active_renderer.render(mask_private_error(info))
        return _to_response(rendered.status_code, rendered.body, rendered.media_type, rendered.headers)

    async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
        """Render Pydantic validation errors as a 422 ``validation_error``."""
        return _render(
            ErrorInfo(
                kind=ErrorKind.INVALID,
                code="validation_error",
                detail="Request validation failed.",
                params={"errors": _sanitize_validation_errors(exc.errors())},
                status_override=_HTTP_422,
            )
        )

    async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
        """Render HTTP exceptions: 5xx masked, 4xx preserved with their detail."""
        if exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR:
            info = ErrorInfo(
                kind=ErrorKind.INTERNAL,
                code=INTERNAL_ERROR_CODE,
                status_override=exc.status_code,
                headers=exc.headers,
            )
        else:
            info = ErrorInfo(
                kind=_KIND_BY_HTTP_STATUS.get(exc.status_code, ErrorKind.INVALID),
                code="http_error",
                detail=str(exc.detail),
                status_override=exc.status_code,
                headers=exc.headers,
            )
        return _render(info)

    async def service_error_handler(request: Request, exc: ServiceError) -> JSONResponse:
        """Map :class:`ServiceError` to its kind's status; mask non-public ones."""
        if not exc.public:
            logger.warning(
                "Private service error occurred: %s",
                exc.code,
                extra={"error_code": exc.code, "error_kind": exc.kind, "params": exc.params},
            )
        return _render(ErrorInfo.from_service_error(exc))

    async def deadline_exceeded_handler(request: Request, exc: LibraryDeadlineExceededError) -> JSONResponse:
        """Convert ``deadline_budget.DeadlineExceededError`` into a 504 response."""
        logger.warning(
            "Request deadline exceeded",
            extra={
                "budget_seconds": exc.budget_seconds,
                "elapsed_seconds": exc.elapsed_seconds,
                "path": str(request.url.path),
                "method": request.method,
            },
        )
        return _render(
            ErrorInfo(
                kind=ErrorKind.DEADLINE_EXCEEDED,
                code="deadline_exceeded",
                detail="Request took too long to process.",
                params={"budget_seconds": exc.budget_seconds, "elapsed_seconds": exc.elapsed_seconds},
            )
        )

    async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
        """Final fallback: log the traceback and return a masked 500."""
        logger.exception("Unhandled exception while processing request", exc_info=True)
        return _render(_MASKED_INTERNAL)

    app.add_exception_handler(RequestValidationError, validation_error_handler)  # type: ignore[arg-type]
    app.add_exception_handler(StarletteHTTPException, http_exception_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ServiceError, service_error_handler)  # type: ignore[arg-type]
    app.add_exception_handler(LibraryDeadlineExceededError, deadline_exceeded_handler)  # type: ignore[arg-type]
    app.add_exception_handler(Exception, unhandled_exception_handler)

setup_metrics_instrumentator(app, *, config=None, metrics_path=DEFAULT_METRICS_PATH)

Instrument app and expose Prometheus metrics at metrics_path.

Raises:

Type Description
ImportError

If prometheus-fastapi-instrumentator is not installed.

Source code in servicewright/adapters/fastapi/metrics.py
def setup_metrics_instrumentator(
    app: FastAPI,
    *,
    config: MetricsInstrumentatorConfig | None = None,
    metrics_path: str = DEFAULT_METRICS_PATH,
) -> None:
    """Instrument ``app`` and expose Prometheus metrics at ``metrics_path``.

    Raises:
        ImportError: If ``prometheus-fastapi-instrumentator`` is not installed.
    """
    try:
        from prometheus_fastapi_instrumentator import Instrumentator
    except ImportError as exc:
        raise ImportError(_INSTALL_HINT_METRICS) from exc

    cfg = config or MetricsInstrumentatorConfig()

    init_kwargs: dict[str, Any] = {
        "should_instrument_requests_inprogress": True,
        "excluded_handlers": ["/system/*"],
    } | cfg.init_kwargs

    instrument_kwargs: dict[str, Any] = dict(cfg.instrument_kwargs)

    expose_kwargs: dict[str, Any] = {
        "include_in_schema": False,
        "should_gzip": False,
        "endpoint": metrics_path,
        "tags": ["system"],
    } | cfg.expose_kwargs

    Instrumentator(**init_kwargs).instrument(app, **instrument_kwargs).expose(app, **expose_kwargs)

adapters.litestar

Litestar entrypoint adapter ([litestar] extra).

HealthConfig dataclass

Configuration for the /system health probe routes.

Source code in servicewright/adapters/litestar/config.py
@dataclass(slots=True)
class HealthConfig:
    """Configuration for the ``/system`` health probe routes."""

    enabled: bool = True
    liveness_path: str = DEFAULT_LIVENESS_PATH
    readiness_path: str = DEFAULT_READINESS_PATH

LitestarConfig dataclass

Self-contained configuration for a Litestar HTTP server entrypoint.

Taken at construction, never read from global settings, so the AppSpec stays transport-neutral.

Source code in servicewright/adapters/litestar/config.py
@dataclass(slots=True)
class LitestarConfig:
    """Self-contained configuration for a Litestar HTTP server entrypoint.

    Taken at construction, never read from global settings, so the AppSpec stays
    transport-neutral.
    """

    host: str = DEFAULT_LITESTAR_HOST
    port: int = DEFAULT_LITESTAR_PORT
    graceful_timeout: float = DEFAULT_GRACEFUL_TIMEOUT_SECONDS

    # Litestar app construction (forwarded to ``Litestar(**litestar_kwargs)``).
    litestar_kwargs: dict[str, Any] = field(default_factory=dict)

    # uvicorn server construction (forwarded to ``uvicorn.Config(**uvicorn_kwargs)``).
    uvicorn_kwargs: dict[str, Any] = field(default_factory=dict)

    # Components.
    health: HealthConfig = field(default_factory=HealthConfig)
    unit_scope: bool = True
    """Open one ``UnitScope`` per request (``UnitScopeMiddleware``, outermost) and
    provide it app-wide as the reserved ``unit_scope`` dependency.

    Set ``False`` when the framework's own DI integration already owns the
    request scope — dishka's ``setup_dishka`` for instance — so the two never
    open two scopes per request. Neither the middleware nor the dependency is
    installed then, and ``current_unit_scope()`` raises ``LookupError``.
    """

    @property
    def address(self) -> str:
        """Return the ``host:port`` bind address."""
        return f"{self.host}:{self.port}"

address property

Return the host:port bind address.

unit_scope = True class-attribute instance-attribute

Open one UnitScope per request (UnitScopeMiddleware, outermost) and provide it app-wide as the reserved unit_scope dependency.

Set False when the framework's own DI integration already owns the request scope — dishka's setup_dishka for instance — so the two never open two scopes per request. Neither the middleware nor the dependency is installed then, and current_unit_scope() raises LookupError.

LitestarEntrypoint

Bases: ServerEntrypoint

A Litestar server entrypoint driven by the :class:Host.

Parameters:

Name Type Description Default
config LitestarConfig | None

Self-contained server configuration (NOT read from settings).

None
route_handlers tuple[Any, ...]

Litestar route handlers / routers to register on the app.

()
route_registerer RouteRegisterer | None

Optional callback returning extra route handlers, resolved at bind time with the :class:ServiceContext.

None
configure_app ConfigureApp | None

Final hook called with (app, ctx) after the app is built.

None
kind str

Telemetry label (default "http").

'http'
essential bool

Whether the entrypoint's exit/failure stops the process.

True
Source code in servicewright/adapters/litestar/entrypoint.py
class LitestarEntrypoint(ServerEntrypoint):
    """A Litestar server entrypoint driven by the :class:`Host`.

    Args:
        config: Self-contained server configuration (NOT read from settings).
        route_handlers: Litestar route handlers / routers to register on the app.
        route_registerer: Optional callback returning extra route handlers,
            resolved at ``bind`` time with the :class:`ServiceContext`.
        configure_app: Final hook called with ``(app, ctx)`` after the app is built.
        kind: Telemetry label (default ``"http"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        config: LitestarConfig | None = None,
        route_handlers: tuple[Any, ...] = (),
        route_registerer: RouteRegisterer | None = None,
        configure_app: ConfigureApp | None = None,
        kind: str = "http",
        essential: bool = True,
    ) -> None:
        self._config = config if config is not None else LitestarConfig()
        self._route_handlers = tuple(route_handlers)
        self._route_registerer = route_registerer
        self._configure_app = configure_app
        self.kind = kind
        self.essential = essential

        self._app: Litestar | None = None
        self._runner = UvicornRunner(
            host=self._config.host,
            port=self._config.port,
            graceful_timeout=self._config.graceful_timeout,
            uvicorn_kwargs=self._config.uvicorn_kwargs,
            label="Litestar",
        )

    @property
    def config(self) -> LitestarConfig:
        """The server configuration."""
        return self._config

    @property
    def app(self) -> Litestar | None:
        """The built Litestar application (``None`` before :meth:`bind`)."""
        return self._app

    @property
    def bound_port(self) -> int | None:
        """The actually bound port (useful when ``config.port == 0``)."""
        return self._runner.bound_port

    async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
        """Build the Litestar app and open the listening socket.

        Binding here (not in :meth:`serve`) turns a port clash into an ``OSError``
        during startup instead of a process that reports ready and serves nothing.
        """
        self._app = await self.build_app(ctx)
        bound_port = self._runner.bind()
        logger.info(
            "Litestar entrypoint bound",
            extra={"service": ctx.service_name, "address": self._config.address, "port": bound_port},
        )

    async def build_app(self, ctx: ServiceContext[Any, Any]) -> Litestar:
        """Construct a fully-configured Litestar app from the :class:`ServiceContext`.

        Exposed for testability: callers can build the app without driving the
        serve loop. The app deliberately has NO container-managing lifespan —
        the Host owns the application scope.
        """
        route_handlers: list[Any] = list(self._route_handlers)
        route_handlers.extend(await self._collect_registered_routes(ctx))

        if self._config.health.enabled:
            route_handlers.extend(
                build_health_routes(
                    ctx.health,
                    liveness_path=self._config.health.liveness_path,
                    readiness_path=self._config.health.readiness_path,
                )
            )

        # User litestar_kwargs are the BASE; the framework-managed keys are then
        # merged ON TOP so they can never be silently dropped (Litestar is built in
        # one call, so unlike FastAPI we cannot add middleware/deps post-construction).
        user_kwargs = dict(self._config.litestar_kwargs)
        user_middleware = list(user_kwargs.pop("middleware", []))
        user_dependencies = dict(user_kwargs.pop("dependencies", {}))
        user_route_handlers = list(user_kwargs.pop("route_handlers", []))
        # The Host owns observability/logging; Litestar must NOT install its own
        # LoggingConfig (it reconfigures the root logger via dictConfig).
        user_kwargs.pop("logging_config", None)

        middleware: list[Any] = list(user_middleware)
        dependencies: dict[str, Any] = dict(user_dependencies)
        if self._config.unit_scope:
            # UnitScopeMiddleware outermost so the per-request scope wraps everything,
            # and the unit_scope dependency cannot be overridden by the user. Both are
            # skipped when the framework's own DI integration owns the request scope.
            middleware.insert(0, UnitScopeMiddleware(ctx.container))
            dependencies["unit_scope"] = Provide(get_unit_scope, sync_to_thread=False)

        params: dict[str, Any] = {
            **user_kwargs,
            "route_handlers": [*route_handlers, *user_route_handlers],
            "middleware": middleware,
            "dependencies": dependencies,
            "logging_config": None,
        }
        app = Litestar(**params)

        if self._configure_app is not None:
            self._configure_app(app, ctx)

        return app

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Serve on the bound socket until the host's ``stop`` event is set.

        Returns while still accepting: the Host flips readiness to false first
        and only then calls :meth:`drain`, which closes the listener.
        """
        if self._app is None:
            raise RuntimeError("serve() called before bind()")
        await self._runner.serve(self._app, stop=stop)

    async def drain(self, grace: float) -> None:
        """Close the listener and let in-flight requests finish within ``grace``."""
        await self._runner.drain(grace)

    async def stop(self) -> None:
        """Hard stop the server immediately (idempotent with :meth:`drain`)."""
        await self._runner.stop()

    async def _collect_registered_routes(self, ctx: ServiceContext[Any, Any]) -> list[Any]:
        if self._route_registerer is None:
            return []
        result = self._route_registerer(ctx)
        if inspect.isawaitable(result):
            return list(await result)
        return list(result)

app property

The built Litestar application (None before :meth:bind).

bound_port property

The actually bound port (useful when config.port == 0).

config property

The server configuration.

bind(ctx) async

Build the Litestar app and open the listening socket.

Binding here (not in :meth:serve) turns a port clash into an OSError during startup instead of a process that reports ready and serves nothing.

Source code in servicewright/adapters/litestar/entrypoint.py
async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
    """Build the Litestar app and open the listening socket.

    Binding here (not in :meth:`serve`) turns a port clash into an ``OSError``
    during startup instead of a process that reports ready and serves nothing.
    """
    self._app = await self.build_app(ctx)
    bound_port = self._runner.bind()
    logger.info(
        "Litestar entrypoint bound",
        extra={"service": ctx.service_name, "address": self._config.address, "port": bound_port},
    )

build_app(ctx) async

Construct a fully-configured Litestar app from the :class:ServiceContext.

Exposed for testability: callers can build the app without driving the serve loop. The app deliberately has NO container-managing lifespan — the Host owns the application scope.

Source code in servicewright/adapters/litestar/entrypoint.py
async def build_app(self, ctx: ServiceContext[Any, Any]) -> Litestar:
    """Construct a fully-configured Litestar app from the :class:`ServiceContext`.

    Exposed for testability: callers can build the app without driving the
    serve loop. The app deliberately has NO container-managing lifespan —
    the Host owns the application scope.
    """
    route_handlers: list[Any] = list(self._route_handlers)
    route_handlers.extend(await self._collect_registered_routes(ctx))

    if self._config.health.enabled:
        route_handlers.extend(
            build_health_routes(
                ctx.health,
                liveness_path=self._config.health.liveness_path,
                readiness_path=self._config.health.readiness_path,
            )
        )

    # User litestar_kwargs are the BASE; the framework-managed keys are then
    # merged ON TOP so they can never be silently dropped (Litestar is built in
    # one call, so unlike FastAPI we cannot add middleware/deps post-construction).
    user_kwargs = dict(self._config.litestar_kwargs)
    user_middleware = list(user_kwargs.pop("middleware", []))
    user_dependencies = dict(user_kwargs.pop("dependencies", {}))
    user_route_handlers = list(user_kwargs.pop("route_handlers", []))
    # The Host owns observability/logging; Litestar must NOT install its own
    # LoggingConfig (it reconfigures the root logger via dictConfig).
    user_kwargs.pop("logging_config", None)

    middleware: list[Any] = list(user_middleware)
    dependencies: dict[str, Any] = dict(user_dependencies)
    if self._config.unit_scope:
        # UnitScopeMiddleware outermost so the per-request scope wraps everything,
        # and the unit_scope dependency cannot be overridden by the user. Both are
        # skipped when the framework's own DI integration owns the request scope.
        middleware.insert(0, UnitScopeMiddleware(ctx.container))
        dependencies["unit_scope"] = Provide(get_unit_scope, sync_to_thread=False)

    params: dict[str, Any] = {
        **user_kwargs,
        "route_handlers": [*route_handlers, *user_route_handlers],
        "middleware": middleware,
        "dependencies": dependencies,
        "logging_config": None,
    }
    app = Litestar(**params)

    if self._configure_app is not None:
        self._configure_app(app, ctx)

    return app

drain(grace) async

Close the listener and let in-flight requests finish within grace.

Source code in servicewright/adapters/litestar/entrypoint.py
async def drain(self, grace: float) -> None:
    """Close the listener and let in-flight requests finish within ``grace``."""
    await self._runner.drain(grace)

serve(*, stop) async

Serve on the bound socket until the host's stop event is set.

Returns while still accepting: the Host flips readiness to false first and only then calls :meth:drain, which closes the listener.

Source code in servicewright/adapters/litestar/entrypoint.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Serve on the bound socket until the host's ``stop`` event is set.

    Returns while still accepting: the Host flips readiness to false first
    and only then calls :meth:`drain`, which closes the listener.
    """
    if self._app is None:
        raise RuntimeError("serve() called before bind()")
    await self._runner.serve(self._app, stop=stop)

stop() async

Hard stop the server immediately (idempotent with :meth:drain).

Source code in servicewright/adapters/litestar/entrypoint.py
async def stop(self) -> None:
    """Hard stop the server immediately (idempotent with :meth:`drain`)."""
    await self._runner.stop()

LitestarPlugin

Declarative wiring: register a :class:LitestarEntrypoint on the host.

Pass the same arguments as :class:LitestarEntrypoint; on_register builds it and adds it to the host.

Source code in servicewright/adapters/litestar/entrypoint.py
class LitestarPlugin:
    """Declarative wiring: register a :class:`LitestarEntrypoint` on the host.

    Pass the same arguments as :class:`LitestarEntrypoint`; ``on_register`` builds
    it and adds it to the host.
    """

    def __init__(
        self,
        *,
        config: LitestarConfig | None = None,
        route_handlers: tuple[Any, ...] = (),
        route_registerer: RouteRegisterer | None = None,
        configure_app: ConfigureApp | None = None,
        kind: str = "http",
        essential: bool = True,
    ) -> None:
        self._entrypoint = LitestarEntrypoint(
            config=config,
            route_handlers=route_handlers,
            route_registerer=route_registerer,
            configure_app=configure_app,
            kind=kind,
            essential=essential,
        )

    @property
    def entrypoint(self) -> LitestarEntrypoint:
        """The entrypoint that will be registered on the host."""
        return self._entrypoint

    def on_register(self, spec: Any, host: Any) -> None:
        """Append the Litestar entrypoint to the host."""
        host.add_entrypoint(self._entrypoint)

entrypoint property

The entrypoint that will be registered on the host.

on_register(spec, host)

Append the Litestar entrypoint to the host.

Source code in servicewright/adapters/litestar/entrypoint.py
def on_register(self, spec: Any, host: Any) -> None:
    """Append the Litestar entrypoint to the host."""
    host.add_entrypoint(self._entrypoint)

UnitScopeMiddleware

Bases: ASGIMiddleware

Open one UnitScope per request and expose it two ways.

Added to the Litestar app by the :class:LitestarEntrypoint unless LitestarConfig.unit_scope is off. The scope carries the Request as its context and is closed/reset after the response is produced. Restricted to HTTP scopes (websockets/lifespan pass through untouched).

Source code in servicewright/adapters/litestar/unit_scope.py
class UnitScopeMiddleware(ASGIMiddleware):
    """Open one ``UnitScope`` per request and expose it two ways.

    Added to the Litestar app by the :class:`LitestarEntrypoint` unless
    ``LitestarConfig.unit_scope`` is off. The scope carries the ``Request`` as
    its ``context`` and is closed/reset after the response is produced.
    Restricted to HTTP scopes (websockets/lifespan pass through untouched).
    """

    scopes = (ScopeType.HTTP,)

    def __init__(self, container: DependencyContainerProtocol) -> None:
        self._container = container

    async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None:
        """Wrap the request in a fresh unit scope bound to scope state + a contextvar."""
        request: Request = Request(scope)
        async with self._container.unit_scope({"request": request}) as unit_scope:
            connection_state = scope.setdefault("state", {})
            connection_state[_STATE_KEY] = unit_scope
            token = _current_unit_scope.set(unit_scope)
            try:
                await next_app(scope, receive, send)
            finally:
                _current_unit_scope.reset(token)

handle(scope, receive, send, next_app) async

Wrap the request in a fresh unit scope bound to scope state + a contextvar.

Source code in servicewright/adapters/litestar/unit_scope.py
async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None:
    """Wrap the request in a fresh unit scope bound to scope state + a contextvar."""
    request: Request = Request(scope)
    async with self._container.unit_scope({"request": request}) as unit_scope:
        connection_state = scope.setdefault("state", {})
        connection_state[_STATE_KEY] = unit_scope
        token = _current_unit_scope.set(unit_scope)
        try:
            await next_app(scope, receive, send)
        finally:
            _current_unit_scope.reset(token)

build_health_routes(health, *, liveness_path, readiness_path)

Return /system liveness + readiness route handlers bound to health.

Parameters:

Name Type Description Default
health HealthRegistry

The transport-agnostic registry to read state from.

required
liveness_path str

Route path for the liveness probe.

required
readiness_path str

Route path for the readiness probe.

required

Returns:

Type Description
list[HTTPRouteHandler]

A list of Litestar route handlers ready to pass to Litestar(route_handlers=...).

Source code in servicewright/adapters/litestar/configurators.py
def build_health_routes(health: HealthRegistry, *, liveness_path: str, readiness_path: str) -> list[HTTPRouteHandler]:
    """Return ``/system`` liveness + readiness route handlers bound to ``health``.

    Args:
        health: The transport-agnostic registry to read state from.
        liveness_path: Route path for the liveness probe.
        readiness_path: Route path for the readiness probe.

    Returns:
        A list of Litestar route handlers ready to pass to ``Litestar(route_handlers=...)``.
    """

    @get(liveness_path, media_type=MediaType.JSON, tags=["health"], include_in_schema=False)
    async def liveness() -> dict[str, str]:
        await health.liveness()
        return {"status": "ok"}

    @get(readiness_path, tags=["health"], include_in_schema=False)
    async def readiness() -> Response:
        report = await health.readiness()
        status_code = HTTP_200_OK if report.healthy else HTTP_503_SERVICE_UNAVAILABLE
        return Response(
            content={"status": "ok" if report.healthy else "unhealthy"},
            status_code=status_code,
            media_type=MediaType.JSON,
        )

    return [liveness, readiness]

current_unit_scope()

Return the :class:UnitScopeProtocol for the in-flight Litestar request.

Raises:

Type Description
LookupError

If called outside a request handled by :class:UnitScopeMiddleware.

Source code in servicewright/adapters/litestar/unit_scope.py
def current_unit_scope() -> UnitScopeProtocol:
    """Return the :class:`UnitScopeProtocol` for the in-flight Litestar request.

    Raises:
        LookupError: If called outside a request handled by
            :class:`UnitScopeMiddleware`.
    """
    try:
        return _current_unit_scope.get()
    except LookupError as exc:
        raise LookupError(
            "No active Litestar unit scope; current_unit_scope() must be called inside a request "
            "served by a LitestarEntrypoint with UnitScopeMiddleware installed "
            "(LitestarConfig.unit_scope=True, the default)."
        ) from exc

get_unit_scope(request)

Litestar dependency returning the per-request unit scope.

Resolves from the ASGI connection scope state (set by :class:UnitScopeMiddleware).

Example

from litestar import get from litestar.di import Provide from servicewright.adapters.litestar import get_unit_scope @get("/users/{user_id:str}", dependencies={"unit_scope": Provide(get_unit_scope)}) ... async def handler(user_id: str, unit_scope: object) -> dict: ... use_case = await unit_scope.get(MyUseCase) ... return await use_case.execute(user_id)

Source code in servicewright/adapters/litestar/unit_scope.py
def get_unit_scope(request: Request) -> UnitScopeProtocol:
    """Litestar dependency returning the per-request unit scope.

    Resolves from the ASGI connection scope state (set by
    :class:`UnitScopeMiddleware`).

    Example:
        >>> from litestar import get
        >>> from litestar.di import Provide
        >>> from servicewright.adapters.litestar import get_unit_scope
        >>> @get("/users/{user_id:str}", dependencies={"unit_scope": Provide(get_unit_scope)})
        ... async def handler(user_id: str, unit_scope: object) -> dict:
        ...     use_case = await unit_scope.get(MyUseCase)
        ...     return await use_case.execute(user_id)
    """
    state = request.scope.get("state") or {}
    scope: UnitScopeProtocol | None = state.get(_STATE_KEY)
    if scope is None:
        raise LookupError(
            "No active Litestar unit scope on the connection state; the LitestarEntrypoint "
            "UnitScopeMiddleware must be installed (LitestarConfig.unit_scope=True, the default) "
            "for get_unit_scope() to resolve."
        )
    return scope

adapters.grpc

gRPC entrypoint adapter ([grpc] extra).

GrpcConfig dataclass

Configuration for a gRPC server entrypoint.

Satisfies grpc_server_kit.protocols.GrpcServerSettingsProtocol so it can be consumed directly by the grpc-server-kit primitives.

Source code in servicewright/adapters/grpc/config.py
@dataclass(slots=True)
class GrpcConfig:
    """Configuration for a gRPC server entrypoint.

    Satisfies ``grpc_server_kit.protocols.GrpcServerSettingsProtocol`` so it can
    be consumed directly by the grpc-server-kit primitives.
    """

    host: str = DEFAULT_GRPC_HOST
    port: int = DEFAULT_GRPC_PORT

    grace_period: float = DEFAULT_GRACE_PERIOD_SECONDS
    """Seconds in-flight RPCs may finish in after intake stops, on drain.

    This is the entrypoint's own drain budget. The Host also allots a grace when
    it calls ``drain(grace)`` and aborts the drain shortly after that allowance
    expires, so the effective budget is ``min(host_grace, grace_period)``:
    lowering this value shortens shutdown, raising it above the Host's allowance
    has no effect.
    """

    enable_reflection: bool = False
    """Serve ``grpc.reflection.v1alpha.ServerReflection`` (default: off).

    Reflection lets tools such as ``grpcurl`` resolve symbols from the process's
    descriptor pool without a local ``.proto``. It is a development convenience
    and is unauthenticated unless an interceptor of yours authenticates it, so
    it is opt-in: turn it on for local/staging, leave it off in production.
    """

    enable_channelz: bool = False
    """Serve ``grpc.channelz.v1.Channelz`` (default: off).

    Channelz is gRPC's debug/introspection service. It exposes ``GetServers`` /
    ``GetServerSockets`` / ``GetSocket`` / ``GetTopChannels`` on the SAME port as
    production traffic, revealing connected peers' remote addresses and
    per-socket call and byte counters. It is unauthenticated unless an
    interceptor of yours authenticates it, so it is opt-in.
    """

    health_service_names: tuple[str, ...] = ()
    """Concrete gRPC service names to report health for, besides the overall one.

    The health servicer always reports the overall (empty-string) name — the one
    a plain ``readinessProbe: {grpc: {port: ...}}`` checks. List your own
    services here (e.g. ``("my.pkg.Orders",)``) to also answer
    ``Check(service="my.pkg.Orders")``; unlisted names are answered NOT_FOUND by
    the standard health servicer.
    """

    health_refresh_interval: float = DEFAULT_HEALTH_REFRESH_INTERVAL_SECONDS
    """Seconds between re-evaluations of readiness onto the health service.

    The gRPC health service is a push API: something must re-evaluate the
    :class:`~servicewright.core.health.HealthRegistry` and push the result. The
    entrypoint polls at this interval while serving, so a failing check flips
    the health service to NOT_SERVING within roughly one interval. Set ``0`` to
    disable polling (the status is then only pushed once at startup).
    """

    # Extra service names exposed via reflection (in addition to the health
    # service and the reflection service itself). Typically your own servicers.
    reflection_service_names: list[str] | None = None

    # --- GrpcServerSettingsProtocol surface (server tuning) -----------------
    max_concurrent_rpcs: int | None = None

    keepalive_time_ms: int = DEFAULT_KEEPALIVE_TIME_MS
    keepalive_timeout_ms: int = DEFAULT_KEEPALIVE_TIMEOUT_MS
    keepalive_permit_without_calls: bool = True
    http2_min_recv_ping_interval_without_data_ms: int = DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS
    http2_max_pings_without_data: int = DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA

    max_send_message_length: int = DEFAULT_MAX_SEND_MESSAGE_LENGTH
    max_receive_message_length: int = DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH
    max_metadata_size: int = DEFAULT_MAX_METADATA_SIZE

    initial_stream_window_size: int = DEFAULT_INITIAL_WINDOW_SIZE
    initial_connection_window_size: int = DEFAULT_INITIAL_WINDOW_SIZE

    max_connection_idle_ms: int | None = None
    max_connection_age_ms: int | None = None
    max_connection_age_grace_ms: int | None = None

    compression_algorithm: str | None = None

    # --- GrpcSslSettingsProtocol surface ------------------------------------
    ssl_enabled: bool = False
    ssl_cert_file: str | None = None
    ssl_key_file: str | None = None
    ssl_ca_file: str | None = None
    ssl_client_auth: bool = False
    ssl_max_cert_size: int | None = None

    def __post_init__(self) -> None:
        """Reject timings the gRPC stack cannot honour."""
        if self.grace_period < 0:
            raise ValueError(f"grace_period must be non-negative, got {self.grace_period}")
        if self.health_refresh_interval < 0:
            raise ValueError(f"health_refresh_interval must be non-negative, got {self.health_refresh_interval}")

    @property
    def address(self) -> str:
        """Return the ``host:port`` bind address."""
        return f"{self.host}:{self.port}"

address property

Return the host:port bind address.

enable_channelz = False class-attribute instance-attribute

Serve grpc.channelz.v1.Channelz (default: off).

Channelz is gRPC's debug/introspection service. It exposes GetServers / GetServerSockets / GetSocket / GetTopChannels on the SAME port as production traffic, revealing connected peers' remote addresses and per-socket call and byte counters. It is unauthenticated unless an interceptor of yours authenticates it, so it is opt-in.

enable_reflection = False class-attribute instance-attribute

Serve grpc.reflection.v1alpha.ServerReflection (default: off).

Reflection lets tools such as grpcurl resolve symbols from the process's descriptor pool without a local .proto. It is a development convenience and is unauthenticated unless an interceptor of yours authenticates it, so it is opt-in: turn it on for local/staging, leave it off in production.

grace_period = DEFAULT_GRACE_PERIOD_SECONDS class-attribute instance-attribute

Seconds in-flight RPCs may finish in after intake stops, on drain.

This is the entrypoint's own drain budget. The Host also allots a grace when it calls drain(grace) and aborts the drain shortly after that allowance expires, so the effective budget is min(host_grace, grace_period): lowering this value shortens shutdown, raising it above the Host's allowance has no effect.

health_refresh_interval = DEFAULT_HEALTH_REFRESH_INTERVAL_SECONDS class-attribute instance-attribute

Seconds between re-evaluations of readiness onto the health service.

The gRPC health service is a push API: something must re-evaluate the :class:~servicewright.core.health.HealthRegistry and push the result. The entrypoint polls at this interval while serving, so a failing check flips the health service to NOT_SERVING within roughly one interval. Set 0 to disable polling (the status is then only pushed once at startup).

health_service_names = () class-attribute instance-attribute

Concrete gRPC service names to report health for, besides the overall one.

The health servicer always reports the overall (empty-string) name — the one a plain readinessProbe: {grpc: {port: ...}} checks. List your own services here (e.g. ("my.pkg.Orders",)) to also answer Check(service="my.pkg.Orders"); unlisted names are answered NOT_FOUND by the standard health servicer.

__post_init__()

Reject timings the gRPC stack cannot honour.

Source code in servicewright/adapters/grpc/config.py
def __post_init__(self) -> None:
    """Reject timings the gRPC stack cannot honour."""
    if self.grace_period < 0:
        raise ValueError(f"grace_period must be non-negative, got {self.grace_period}")
    if self.health_refresh_interval < 0:
        raise ValueError(f"health_refresh_interval must be non-negative, got {self.health_refresh_interval}")

GrpcEntrypoint

Bases: ServerEntrypoint

A gRPC server entrypoint driven by the :class:Host.

Parameters:

Name Type Description Default
config GrpcConfig

Self-contained server configuration (NOT read from settings).

required
servicers ServicerRegisterer

Callback registering servicers on the gRPC server.

required
interceptors Sequence[ServerInterceptor]

Static interceptors. They wrap the servicer inside the unit-scope and metrics interceptors but outside the service-error mapper, so a generic exception handler of yours (e.g. the kit's AsyncExceptionHandlerInterceptor) sees an already-mapped AbortError instead of swallowing the domain error.

()
interceptors_factory InterceptorFactory | None

Optional callback returning extra interceptors, resolved at bind time with the :class:ServiceContext.

None
context_setters Sequence[ContextSetter] | None

Bridges that push the per-RPC context (request id, user id, trace id) into systems keeping their own store — structlog contextvars, OTel Baggage. Defaults to whichever of those is installed; pass an explicit sequence (possibly empty) to override.

None
map_service_errors bool

Convert raised :class:~servicewright.core.errors.ServiceError into the mapped grpc.StatusCode abort (non-public errors masked). Default True.

True
enable_metrics bool

Add the RPC metrics interceptor, recording through the app's configured metrics sink (ObsConfig(metrics=...) + the matching extra, e.g. servicewright[metrics] for prometheus).

False
metrics_prefix str | None

Optional metric name prefix.

None
kind str

Telemetry label (default "grpc").

'grpc'
essential bool

Whether the entrypoint's exit/failure stops the process.

True
Source code in servicewright/adapters/grpc/entrypoint.py
class GrpcEntrypoint(ServerEntrypoint):
    """A gRPC server entrypoint driven by the :class:`Host`.

    Args:
        config: Self-contained server configuration (NOT read from settings).
        servicers: Callback registering servicers on the gRPC server.
        interceptors: Static interceptors. They wrap the servicer *inside* the
            unit-scope and metrics interceptors but *outside* the service-error
            mapper, so a generic exception handler of yours (e.g. the kit's
            ``AsyncExceptionHandlerInterceptor``) sees an already-mapped
            ``AbortError`` instead of swallowing the domain error.
        interceptors_factory: Optional callback returning extra interceptors,
            resolved at ``bind`` time with the :class:`ServiceContext`.
        context_setters: Bridges that push the per-RPC context (request id, user
            id, trace id) into systems keeping their own store — structlog
            contextvars, OTel Baggage. Defaults to whichever of those is
            installed; pass an explicit sequence (possibly empty) to override.
        map_service_errors: Convert raised
            :class:`~servicewright.core.errors.ServiceError` into the mapped
            ``grpc.StatusCode`` abort (non-public errors masked). Default
            ``True``.
        enable_metrics: Add the RPC metrics interceptor, recording through the
            app's configured metrics sink (``ObsConfig(metrics=...)`` + the
            matching extra, e.g. servicewright[metrics] for prometheus).
        metrics_prefix: Optional metric name prefix.
        kind: Telemetry label (default ``"grpc"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        config: GrpcConfig,
        servicers: ServicerRegisterer,
        interceptors: Sequence[grpc.aio.ServerInterceptor] = (),
        interceptors_factory: InterceptorFactory | None = None,
        context_setters: Sequence[ContextSetter] | None = None,
        map_service_errors: bool = True,
        enable_metrics: bool = False,
        metrics_prefix: str | None = None,
        kind: str = "grpc",
        essential: bool = True,
    ) -> None:
        self._config = config
        self._servicers = servicers
        self._static_interceptors: list[grpc.aio.ServerInterceptor] = list(interceptors)
        self._interceptors_factory = interceptors_factory
        self._context_setters = context_setters
        self._map_service_errors = map_service_errors
        self._enable_metrics = enable_metrics
        self._metrics_prefix = metrics_prefix
        self.kind = kind
        self.essential = essential

        self._server: AsyncServer | None = None
        self._health: GrpcHealthBridge | None = None
        self._health_watcher: asyncio.Task[None] | None = None
        self._bound_port: int | None = None
        self._stopped = False

    @property
    def config(self) -> GrpcConfig:
        """The server configuration."""
        return self._config

    @property
    def bound_port(self) -> int | None:
        """The actually bound port (useful when ``config.port == 0``)."""
        return self._bound_port

    async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
        """Create the server, register servicers + health, and bind the port."""
        interceptors = await self._collect_interceptors(ctx)
        self._health = GrpcHealthBridge(ctx.health, service_names=self._config.health_service_names)

        server = create_async_grpc_server(
            interceptors=interceptors,
            settings=self._config,
            register_servicers=self._health.register,
            enable_reflection=self._config.enable_reflection,
            reflection_service_names=self._reflection_service_names(),
            enable_channelz=self._config.enable_channelz,
        )

        await self._run_servicers(server.raw_server, ctx)

        self._bound_port = bind_server_port(server, self._config)
        self._server = server
        logger.info(
            "gRPC entrypoint bound",
            extra={"service": ctx.service_name, "address": self._config.address, "port": self._bound_port},
        )

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Start the server and run until the host's ``stop`` event is set.

        Returns while the server is still accepting: the Host flips readiness to
        false and only then calls :meth:`drain`. The health poller runs for
        exactly this window, so a dependency that fails mid-life flips the gRPC
        health service to ``NOT_SERVING`` without any traffic being refused.
        """
        if self._server is None:
            raise RuntimeError("serve() called before bind()")
        await self._server.start()
        if self._health is not None:
            await self._health.refresh()
            self._health_watcher = asyncio.create_task(self._health.watch(self._config.health_refresh_interval))
        logger.info("gRPC server started", extra={"address": self._config.address})
        try:
            await stop.wait()
        finally:
            await self._cancel_health_watcher()

    async def drain(self, grace: float) -> None:
        """Stop accepting RPCs and let in-flight ones finish within ``grace``.

        The effective budget is ``min(grace, config.grace_period)``: the Host's
        allowance bounds the shutdown, and the entrypoint's own setting can only
        shorten it.
        """
        if self._server is None or self._stopped:
            return
        await self._cancel_health_watcher()
        if self._health is not None:
            await self._health.enter_graceful_shutdown()
        await self._server.stop(min(grace, self._config.grace_period))
        self._stopped = True

    async def stop(self) -> None:
        """Hard stop the server immediately (idempotent with ``drain``)."""
        await self._cancel_health_watcher()
        if self._server is None or self._stopped:
            return
        await self._server.stop(None)
        self._stopped = True

    async def _cancel_health_watcher(self) -> None:
        """Stop the readiness poller, tolerating a never-started one."""
        watcher, self._health_watcher = self._health_watcher, None
        if watcher is None:
            return
        watcher.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await watcher

    async def _collect_interceptors(self, ctx: ServiceContext[Any, Any]) -> list[grpc.aio.ServerInterceptor]:
        # grpc.aio hands control in list order, so the first entry is the
        # OUTERMOST wrapper. UnitScopeInterceptor is therefore first: every
        # downstream interceptor and the servicer see a live per-RPC scope.
        setters = get_default_context_setters() if self._context_setters is None else self._context_setters
        interceptors: list[grpc.aio.ServerInterceptor] = [UnitScopeInterceptor(ctx.container, context_setters=setters)]

        if self._enable_metrics:
            recorder = GrpcServerMetricsRecorder(ctx.observability.metrics, prefix=self._metrics_prefix)
            interceptors.append(AsyncMetricsInterceptor(recorder, service_name=ctx.service_name))

        interceptors.extend(self._static_interceptors)

        if self._interceptors_factory is not None:
            result = self._interceptors_factory(ctx)
            interceptors.extend(await _resolve(result))

        # Innermost, i.e. closest to the servicer: a ServiceError must become a
        # mapped abort BEFORE any user interceptor sees it. A generic exception
        # handler placed by the user (the kit's AsyncExceptionHandlerInterceptor
        # maps unknown exceptions to INTERNAL) would otherwise swallow every
        # domain error and turn a deliberate NOT_FOUND into INTERNAL. It stays
        # inside the metrics interceptor, so aborts are still recorded with the
        # status the client actually receives.
        if self._map_service_errors:
            interceptors.append(ServiceErrorInterceptor())

        return interceptors

    async def _run_servicers(self, server: grpc.aio.Server, ctx: ServiceContext[Any, Any]) -> None:
        result = self._servicers(server, ctx)
        if asyncio.iscoroutine(result):
            await result

    def _reflection_service_names(self) -> list[str]:
        names = [DEFAULT_HEALTH_SERVICE_NAME]
        if self._config.reflection_service_names:
            names.extend(self._config.reflection_service_names)
        return names

bound_port property

The actually bound port (useful when config.port == 0).

config property

The server configuration.

bind(ctx) async

Create the server, register servicers + health, and bind the port.

Source code in servicewright/adapters/grpc/entrypoint.py
async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
    """Create the server, register servicers + health, and bind the port."""
    interceptors = await self._collect_interceptors(ctx)
    self._health = GrpcHealthBridge(ctx.health, service_names=self._config.health_service_names)

    server = create_async_grpc_server(
        interceptors=interceptors,
        settings=self._config,
        register_servicers=self._health.register,
        enable_reflection=self._config.enable_reflection,
        reflection_service_names=self._reflection_service_names(),
        enable_channelz=self._config.enable_channelz,
    )

    await self._run_servicers(server.raw_server, ctx)

    self._bound_port = bind_server_port(server, self._config)
    self._server = server
    logger.info(
        "gRPC entrypoint bound",
        extra={"service": ctx.service_name, "address": self._config.address, "port": self._bound_port},
    )

drain(grace) async

Stop accepting RPCs and let in-flight ones finish within grace.

The effective budget is min(grace, config.grace_period): the Host's allowance bounds the shutdown, and the entrypoint's own setting can only shorten it.

Source code in servicewright/adapters/grpc/entrypoint.py
async def drain(self, grace: float) -> None:
    """Stop accepting RPCs and let in-flight ones finish within ``grace``.

    The effective budget is ``min(grace, config.grace_period)``: the Host's
    allowance bounds the shutdown, and the entrypoint's own setting can only
    shorten it.
    """
    if self._server is None or self._stopped:
        return
    await self._cancel_health_watcher()
    if self._health is not None:
        await self._health.enter_graceful_shutdown()
    await self._server.stop(min(grace, self._config.grace_period))
    self._stopped = True

serve(*, stop) async

Start the server and run until the host's stop event is set.

Returns while the server is still accepting: the Host flips readiness to false and only then calls :meth:drain. The health poller runs for exactly this window, so a dependency that fails mid-life flips the gRPC health service to NOT_SERVING without any traffic being refused.

Source code in servicewright/adapters/grpc/entrypoint.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Start the server and run until the host's ``stop`` event is set.

    Returns while the server is still accepting: the Host flips readiness to
    false and only then calls :meth:`drain`. The health poller runs for
    exactly this window, so a dependency that fails mid-life flips the gRPC
    health service to ``NOT_SERVING`` without any traffic being refused.
    """
    if self._server is None:
        raise RuntimeError("serve() called before bind()")
    await self._server.start()
    if self._health is not None:
        await self._health.refresh()
        self._health_watcher = asyncio.create_task(self._health.watch(self._config.health_refresh_interval))
    logger.info("gRPC server started", extra={"address": self._config.address})
    try:
        await stop.wait()
    finally:
        await self._cancel_health_watcher()

stop() async

Hard stop the server immediately (idempotent with drain).

Source code in servicewright/adapters/grpc/entrypoint.py
async def stop(self) -> None:
    """Hard stop the server immediately (idempotent with ``drain``)."""
    await self._cancel_health_watcher()
    if self._server is None or self._stopped:
        return
    await self._server.stop(None)
    self._stopped = True

GrpcHealthBridge

Owns the aio health servicer and syncs it with a :class:HealthRegistry.

Source code in servicewright/adapters/grpc/health.py
class GrpcHealthBridge:
    """Owns the aio health servicer and syncs it with a :class:`HealthRegistry`."""

    def __init__(self, registry: HealthRegistry, *, service_names: tuple[str, ...] = ()) -> None:
        self._registry = registry
        self._servicer = health_aio.HealthServicer()
        # Report status for the overall server plus any concrete service names.
        self._service_names: tuple[str, ...] = (_OVERALL_SERVICE, *service_names)

    def register(self, server: grpc.aio.Server) -> None:
        """Add the health servicer to ``server``."""
        health_pb2_grpc.add_HealthServicer_to_server(self._servicer, server)

    async def refresh(self) -> None:
        """Evaluate readiness and push the verdict onto the health servicer.

        Runs every registered health check (same contract as the HTTP readiness
        route), so a dependency outage flips the health service to
        ``NOT_SERVING``.
        """
        report = await self._registry.readiness()
        status = _SERVING if report.healthy else _NOT_SERVING
        for name in self._service_names:
            await self._servicer.set(name, status)

    async def watch(self, interval: float) -> None:
        """Re-evaluate readiness every ``interval`` seconds until cancelled.

        A refresh that fails is logged and retried on the next tick: a health
        poller must never take the server down.
        """
        if interval <= 0:
            return
        while True:
            await asyncio.sleep(interval)
            try:
                await self.refresh()
            except asyncio.CancelledError:
                raise
            except Exception:
                logger.exception("gRPC health refresh failed")

    async def enter_graceful_shutdown(self) -> None:
        """Flip every tracked service to ``NOT_SERVING`` for draining."""
        await self._servicer.enter_graceful_shutdown()

enter_graceful_shutdown() async

Flip every tracked service to NOT_SERVING for draining.

Source code in servicewright/adapters/grpc/health.py
async def enter_graceful_shutdown(self) -> None:
    """Flip every tracked service to ``NOT_SERVING`` for draining."""
    await self._servicer.enter_graceful_shutdown()

refresh() async

Evaluate readiness and push the verdict onto the health servicer.

Runs every registered health check (same contract as the HTTP readiness route), so a dependency outage flips the health service to NOT_SERVING.

Source code in servicewright/adapters/grpc/health.py
async def refresh(self) -> None:
    """Evaluate readiness and push the verdict onto the health servicer.

    Runs every registered health check (same contract as the HTTP readiness
    route), so a dependency outage flips the health service to
    ``NOT_SERVING``.
    """
    report = await self._registry.readiness()
    status = _SERVING if report.healthy else _NOT_SERVING
    for name in self._service_names:
        await self._servicer.set(name, status)

register(server)

Add the health servicer to server.

Source code in servicewright/adapters/grpc/health.py
def register(self, server: grpc.aio.Server) -> None:
    """Add the health servicer to ``server``."""
    health_pb2_grpc.add_HealthServicer_to_server(self._servicer, server)

watch(interval) async

Re-evaluate readiness every interval seconds until cancelled.

A refresh that fails is logged and retried on the next tick: a health poller must never take the server down.

Source code in servicewright/adapters/grpc/health.py
async def watch(self, interval: float) -> None:
    """Re-evaluate readiness every ``interval`` seconds until cancelled.

    A refresh that fails is logged and retried on the next tick: a health
    poller must never take the server down.
    """
    if interval <= 0:
        return
    while True:
        await asyncio.sleep(interval)
        try:
            await self.refresh()
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("gRPC health refresh failed")

GrpcPlugin

Declarative wiring: register a :class:GrpcEntrypoint on the host.

Pass the same arguments as :class:GrpcEntrypoint; on_register builds it and adds it to the host.

Source code in servicewright/adapters/grpc/entrypoint.py
class GrpcPlugin:
    """Declarative wiring: register a :class:`GrpcEntrypoint` on the host.

    Pass the same arguments as :class:`GrpcEntrypoint`; ``on_register`` builds it
    and adds it to the host.
    """

    def __init__(
        self,
        *,
        config: GrpcConfig,
        servicers: ServicerRegisterer,
        interceptors: Sequence[grpc.aio.ServerInterceptor] = (),
        interceptors_factory: InterceptorFactory | None = None,
        context_setters: Sequence[ContextSetter] | None = None,
        map_service_errors: bool = True,
        enable_metrics: bool = False,
        metrics_prefix: str | None = None,
        kind: str = "grpc",
        essential: bool = True,
    ) -> None:
        self._entrypoint = GrpcEntrypoint(
            config=config,
            servicers=servicers,
            interceptors=interceptors,
            interceptors_factory=interceptors_factory,
            context_setters=context_setters,
            map_service_errors=map_service_errors,
            enable_metrics=enable_metrics,
            metrics_prefix=metrics_prefix,
            kind=kind,
            essential=essential,
        )

    @property
    def entrypoint(self) -> GrpcEntrypoint:
        """The entrypoint that will be registered on the host."""
        return self._entrypoint

    def on_register(self, spec: Any, host: Any) -> None:
        """Append the gRPC entrypoint to the host."""
        host.add_entrypoint(self._entrypoint)

entrypoint property

The entrypoint that will be registered on the host.

on_register(spec, host)

Append the gRPC entrypoint to the host.

Source code in servicewright/adapters/grpc/entrypoint.py
def on_register(self, spec: Any, host: Any) -> None:
    """Append the gRPC entrypoint to the host."""
    host.add_entrypoint(self._entrypoint)

GrpcServerMetricsRecorder

The frozen 5-arg gRPC-server request recorder over generic instruments.

Source code in servicewright/adapters/grpc/metrics.py
class GrpcServerMetricsRecorder:
    """The frozen 5-arg gRPC-server request recorder over generic instruments."""

    def __init__(
        self,
        sink: MetricsSinkProtocol,
        *,
        prefix: str | None = None,
        buckets: tuple[float, ...] = DEFAULT_GRPC_BUCKETS,
    ) -> None:
        self._requests_total = sink.counter(
            make_metric_name(GRPC_REQUESTS_TOTAL, prefix),
            "Total number of gRPC requests",
            GRPC_REQUESTS_TOTAL_LABELS,
        )
        self._request_duration = sink.histogram(
            make_metric_name(GRPC_REQUEST_DURATION_SECONDS, prefix),
            "gRPC request duration in seconds",
            GRPC_REQUEST_DURATION_LABELS,
            buckets=buckets,
        )

    def record_request(self, service: str, method: str, status: str, grpc_code: str, duration: float) -> None:
        """Record one served RPC."""
        self._requests_total.inc(service=service, method=method, status=status, grpc_code=grpc_code)
        self._request_duration.observe(duration, service=service, method=method)

record_request(service, method, status, grpc_code, duration)

Record one served RPC.

Source code in servicewright/adapters/grpc/metrics.py
def record_request(self, service: str, method: str, status: str, grpc_code: str, duration: float) -> None:
    """Record one served RPC."""
    self._requests_total.inc(service=service, method=method, status=status, grpc_code=grpc_code)
    self._request_duration.observe(duration, service=service, method=method)

ServiceErrorInterceptor

Bases: AsyncServerInterceptor

Abort RPCs failing with :class:ServiceError using the mapped status.

Added automatically by :class:GrpcEntrypoint (inside the metrics interceptor, so aborts are recorded with their real status). Any other exception passes through untouched — compose grpc-server-kit's AsyncExceptionHandlerInterceptor for generic exception mapping.

Source code in servicewright/adapters/grpc/errors.py
class ServiceErrorInterceptor(AsyncServerInterceptor):
    """Abort RPCs failing with :class:`ServiceError` using the mapped status.

    Added automatically by :class:`GrpcEntrypoint` (inside the metrics
    interceptor, so aborts are recorded with their real status). Any other
    exception passes through untouched — compose grpc-server-kit's
    ``AsyncExceptionHandlerInterceptor`` for generic exception mapping.
    """

    async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
        """Convert a raised ``ServiceError`` into a mapped gRPC abort."""
        try:
            yield
        except ServiceError as exc:
            await self._abort(call, exc)

    async def _abort(self, call: RpcCall, exc: ServiceError) -> None:
        if not exc.public:
            logger.warning(
                "Private service error occurred: %s",
                exc.code,
                extra={"error_code": exc.code, "error_kind": exc.kind, "params": exc.params},
            )
        info = mask_private_error(ErrorInfo.from_service_error(exc))
        status = GRPC_STATUS_BY_KIND[info.kind]

        # abort() raises grpc.aio.AbortError and never returns.
        await call.context.abort(
            status,
            info.detail or info.code,
            trailing_metadata=((ERROR_CODE_TRAILING_METADATA, info.code),),
        )

around_call(call) async

Convert a raised ServiceError into a mapped gRPC abort.

Source code in servicewright/adapters/grpc/errors.py
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
    """Convert a raised ``ServiceError`` into a mapped gRPC abort."""
    try:
        yield
    except ServiceError as exc:
        await self._abort(call, exc)

UnitScopeInterceptor

Bases: AsyncServerInterceptor

Open one UnitScope per RPC and expose it via a context variable.

This is added first to the interceptor chain by :class:GrpcEntrypoint, so every downstream interceptor and the servicer see a live unit scope. The scope carries the RPC payload (method name, correlation ids, idempotency key, client ip / user agent) as its context, mirrored into the core context store. Correlation ids come from x-request-id / x-user-id / x-tenant-id / x-trace-id metadata (like the HTTP middleware), with the request id auto-generated when the client sent none.

The same values are pushed into the supplied :class:~servicewright.core.context.ContextSetters, which is what bridges them into systems that keep their OWN store — structlog contextvars (so every log line emitted during the RPC carries request_id) and OTel Baggage. :class:GrpcEntrypoint installs :func:~servicewright.adapters.grpc.context.get_default_context_setters by default; a setter raising is logged and never fails the RPC.

Parameters:

Name Type Description Default
container DependencyContainerProtocol

The DI container the per-RPC unit scope is opened on.

required
context_setters Sequence[ContextSetter]

Bridges pushing the RPC context into external systems.

()
Source code in servicewright/adapters/grpc/interceptors.py
class UnitScopeInterceptor(AsyncServerInterceptor):
    """Open one ``UnitScope`` per RPC and expose it via a context variable.

    This is added first to the interceptor chain by :class:`GrpcEntrypoint`, so
    every downstream interceptor and the servicer see a live unit scope. The
    scope carries the RPC payload (method name, correlation ids, idempotency
    key, client ip / user agent) as its ``context``, mirrored into the core
    context store. Correlation ids come from ``x-request-id`` / ``x-user-id`` /
    ``x-tenant-id`` / ``x-trace-id`` metadata (like the HTTP middleware), with
    the request id auto-generated when the client sent none.

    The same values are pushed into the supplied
    :class:`~servicewright.core.context.ContextSetter`s, which is what bridges
    them into systems that keep their OWN store — structlog contextvars (so
    every log line emitted during the RPC carries ``request_id``) and OTel
    Baggage. :class:`GrpcEntrypoint` installs
    :func:`~servicewright.adapters.grpc.context.get_default_context_setters` by
    default; a setter raising is logged and never fails the RPC.

    Args:
        container: The DI container the per-RPC unit scope is opened on.
        context_setters: Bridges pushing the RPC context into external systems.
    """

    def __init__(
        self,
        container: DependencyContainerProtocol,
        *,
        context_setters: Sequence[ContextSetter] = (),
    ) -> None:
        super().__init__()
        self._container = container
        self._context_setters = tuple(context_setters)

    async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
        """Wrap the RPC in a fresh unit scope bound to a context variable."""
        unit_context = self._build_context(call.context, call.method_name)
        # Mirror the RPC payload into the transport-neutral core context store
        # so business code reads it the same way as under any other transport.
        remove_context = bind_context_values(unit_context)
        removers = self._apply_setters(unit_context)
        try:
            async with self._container.unit_scope(unit_context) as scope:
                token = _current_unit_scope.set(scope)
                try:
                    yield
                finally:
                    _current_unit_scope.reset(token)
        finally:
            self._undo_setters(removers)
            remove_context()

    def _apply_setters(self, unit_context: dict[str, Any]) -> list[Callable[[], None]]:
        """Push the RPC context into every setter; return their cleanups."""
        removers: list[Callable[[], None]] = []
        for setter in self._context_setters:
            try:
                remover = setter.set(unit_context)
            except Exception:
                logger.exception("Failed to call gRPC context setter")
                continue
            if remover is not None:
                removers.append(remover)
        return removers

    @staticmethod
    def _undo_setters(removers: list[Callable[[], None]]) -> None:
        """Run the setters' cleanups in reverse order, never raising."""
        for remover in reversed(removers):
            try:
                remover()
            except Exception:
                logger.exception("Failed to call gRPC context cleanup remover")

    @staticmethod
    def _build_context(context: grpc.aio.ServicerContext, method_name: str) -> dict[str, Any]:
        client_ip, user_agent = get_client_context(context)
        idempotency_key = get_idempotency_key(context)
        if idempotency_key is not None and not is_safe_context_id(idempotency_key):
            idempotency_key = None

        unit_context: dict[str, Any] = {
            "grpc_method": method_name,
            "idempotency_key": idempotency_key,
            "client_ip": client_ip,
            "user_agent": user_agent,
        }
        unit_context.update(get_correlation_ids(context))
        if "request_id" not in unit_context:
            unit_context["request_id"] = str(uuid.uuid4())
        return unit_context

around_call(call) async

Wrap the RPC in a fresh unit scope bound to a context variable.

Source code in servicewright/adapters/grpc/interceptors.py
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
    """Wrap the RPC in a fresh unit scope bound to a context variable."""
    unit_context = self._build_context(call.context, call.method_name)
    # Mirror the RPC payload into the transport-neutral core context store
    # so business code reads it the same way as under any other transport.
    remove_context = bind_context_values(unit_context)
    removers = self._apply_setters(unit_context)
    try:
        async with self._container.unit_scope(unit_context) as scope:
            token = _current_unit_scope.set(scope)
            try:
                yield
            finally:
                _current_unit_scope.reset(token)
    finally:
        self._undo_setters(removers)
        remove_context()

current_unit_scope()

Return the :class:UnitScopeProtocol for the in-flight RPC.

Raises:

Type Description
LookupError

If called outside an RPC handled by :class:UnitScopeInterceptor.

Source code in servicewright/adapters/grpc/interceptors.py
def current_unit_scope() -> UnitScopeProtocol:
    """Return the :class:`UnitScopeProtocol` for the in-flight RPC.

    Raises:
        LookupError: If called outside an RPC handled by
            :class:`UnitScopeInterceptor`.
    """
    try:
        return _current_unit_scope.get()
    except LookupError as exc:
        raise LookupError(
            "No active gRPC unit scope; current_unit_scope() must be called inside an RPC "
            "served by a GrpcEntrypoint (UnitScopeInterceptor is added automatically)."
        ) from exc

get_client_context(context)

Return (client_ip, user_agent) extracted from gRPC metadata.

Source code in servicewright/adapters/grpc/metadata.py
def get_client_context(context: grpc.aio.ServicerContext) -> tuple[str | None, str | None]:
    """Return ``(client_ip, user_agent)`` extracted from gRPC metadata."""
    return get_client_ip(context), get_user_agent(context)

get_client_ip(context)

Extract the client IP from gRPC metadata.

Looks for x-forwarded-for first (proxy / load balancer), then x-real-ip (nginx / other reverse proxies).

Source code in servicewright/adapters/grpc/metadata.py
def get_client_ip(context: grpc.aio.ServicerContext) -> str | None:
    """Extract the client IP from gRPC metadata.

    Looks for ``x-forwarded-for`` first (proxy / load balancer), then
    ``x-real-ip`` (nginx / other reverse proxies).
    """
    metadata = context.invocation_metadata()
    if not metadata:
        return None

    metadata_dict = {key.lower(): value for key, value in metadata}
    forwarded = metadata_dict.get(_X_FORWARDED_FOR)
    real_ip = metadata_dict.get(_X_REAL_IP)
    chosen = forwarded or real_ip
    return _decode(chosen) if chosen is not None else None

get_idempotency_key(context)

Extract the idempotency key from gRPC metadata (case-insensitive).

Source code in servicewright/adapters/grpc/metadata.py
def get_idempotency_key(context: grpc.aio.ServicerContext) -> str | None:
    """Extract the idempotency key from gRPC metadata (case-insensitive)."""
    metadata = context.invocation_metadata()
    if not metadata:
        return None

    for key, value in metadata:
        if key.lower() == IDEMPOTENCY_KEY_METADATA:
            return _decode(value)

    return None

get_user_agent(context)

Extract the user agent from gRPC metadata.

Prefers the custom x-user-agent header (to avoid the gRPC-reserved user-agent header), falling back to user-agent.

Source code in servicewright/adapters/grpc/metadata.py
def get_user_agent(context: grpc.aio.ServicerContext) -> str | None:
    """Extract the user agent from gRPC metadata.

    Prefers the custom ``x-user-agent`` header (to avoid the gRPC-reserved
    ``user-agent`` header), falling back to ``user-agent``.
    """
    metadata = context.invocation_metadata()
    if not metadata:
        return None

    user_agent: str | None = None
    for key, value in metadata:
        key_lower = key.lower()
        if key_lower == X_USER_AGENT_METADATA:
            return _decode(value)
        if key_lower == USER_AGENT_METADATA and user_agent is None:
            user_agent = _decode(value)

    return user_agent

adapters.apscheduler4

APScheduler 4.x scheduler entrypoint adapter ([apscheduler4] extra).

DuplicateScheduleError

Bases: SchedulerError

Raised when two or more scheduled jobs share the same id.

Source code in servicewright/adapters/apscheduler4/exceptions.py
class DuplicateScheduleError(SchedulerError):
    """Raised when two or more scheduled jobs share the same ``id``."""

    def __init__(self, duplicates: list[str]) -> None:
        self.duplicates = duplicates
        super().__init__(f"Duplicate schedule IDs detected: {duplicates}")

ScheduledJob dataclass

Description of a single scheduled job.

Attributes:

Name Type Description
id str

Unique identifier for the job (also the APScheduler schedule id).

func Callable[[UnitScopeProtocol], Awaitable[None]]

Async callable invoked as func(scope, *args, **kwargs) where scope is the per-job :class:UnitScopeProtocol.

trigger Trigger

APScheduler :class:~apscheduler.abc.Trigger driving the job.

args Sequence[Any]

Positional arguments passed after scope.

kwargs Mapping[str, Any]

Keyword arguments passed to func.

max_instances int | None

Maximum number of concurrent running jobs for this id (maps to APScheduler v4 configure_task(max_running_jobs=...)).

misfire_grace_time float | None

Seconds after which a misfired run is skipped.

coalesce CoalescePolicy | None

APScheduler v4 :class:~apscheduler.CoalescePolicy controlling how missed runs are collapsed.

Source code in servicewright/adapters/apscheduler4/config.py
@dataclass(frozen=True, slots=True)
class ScheduledJob:
    """Description of a single scheduled job.

    Attributes:
        id: Unique identifier for the job (also the APScheduler schedule id).
        func: Async callable invoked as ``func(scope, *args, **kwargs)`` where
            ``scope`` is the per-job :class:`UnitScopeProtocol`.
        trigger: APScheduler :class:`~apscheduler.abc.Trigger` driving the job.
        args: Positional arguments passed after ``scope``.
        kwargs: Keyword arguments passed to ``func``.
        max_instances: Maximum number of concurrent running jobs for this id
            (maps to APScheduler v4 ``configure_task(max_running_jobs=...)``).
        misfire_grace_time: Seconds after which a misfired run is skipped.
        coalesce: APScheduler v4 :class:`~apscheduler.CoalescePolicy` controlling
            how missed runs are collapsed.
    """

    id: str
    func: Callable[[UnitScopeProtocol], Awaitable[None]]
    trigger: Trigger
    args: Sequence[Any] = ()
    kwargs: Mapping[str, Any] = field(default_factory=dict)
    max_instances: int | None = None
    misfire_grace_time: float | None = None
    coalesce: CoalescePolicy | None = None

SchedulerEntrypoint

Bases: ScopedEntrypoint

An APScheduler-driven scheduler entrypoint driven by the :class:Host.

Parameters:

Name Type Description Default
jobs Sequence[ScheduledJob]

The scheduled jobs to register. Each fires inside a fresh per-job :class:~servicewright.core.contracts.UnitScopeProtocol.

required
kind str

Telemetry label (default "scheduler").

'scheduler'
essential bool

Whether the entrypoint's exit/failure stops the process.

True
Source code in servicewright/adapters/apscheduler4/entrypoint.py
class SchedulerEntrypoint(ScopedEntrypoint):
    """An APScheduler-driven scheduler entrypoint driven by the :class:`Host`.

    Args:
        jobs: The scheduled jobs to register. Each fires inside a fresh per-job
            :class:`~servicewright.core.contracts.UnitScopeProtocol`.
        kind: Telemetry label (default ``"scheduler"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        jobs: Sequence[ScheduledJob],
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        super().__init__()
        self._jobs: list[ScheduledJob] = list(jobs)
        self.kind = kind
        self.essential = essential

        self._registry: dict[str, ScheduledJob] = {}
        self._scheduler: AsyncScheduler | None = None
        # Holds the entered ``AsyncScheduler`` so its lifetime survives serve();
        # closing it (only in stop()) is what actually tears the scheduler down.
        self._stack: contextlib.AsyncExitStack | None = None
        self._stopped = False

    @property
    def jobs(self) -> list[ScheduledJob]:
        """The configured scheduled jobs."""
        return self._jobs

    async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
        """Capture the container, enter the scheduler, and register schedules.

        The :class:`AsyncScheduler` is entered here (not in :meth:`serve`) so it
        outlives ``serve()`` and is still alive when the Host calls
        :meth:`drain` / :meth:`stop`. The scheduler is held open on
        :attr:`_stack`; it is torn down only in :meth:`stop`.

        Raises:
            DuplicateScheduleError: If two jobs share the same ``id``.
        """
        await super().bind(ctx)
        self._registry = self._build_registry(self._jobs)

        stack = contextlib.AsyncExitStack()
        try:
            scheduler = await stack.enter_async_context(AsyncScheduler())
            for job in self._registry.values():
                await self._register_schedule(scheduler, job)
        except BaseException:
            await stack.aclose()
            raise
        self._scheduler = scheduler
        self._stack = stack

        logger.info(
            "Scheduler entrypoint bound",
            extra={"service": ctx.service_name, "jobs_count": len(self._registry)},
        )

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Start the scheduler and run until the host's ``stop`` event is set.

        This only starts the (already-entered) scheduler in the background and
        waits on ``stop``. It deliberately does NOT close or null the scheduler:
        the Host calls :meth:`drain` / :meth:`stop` AFTER ``serve()`` returns and
        needs a live scheduler to drain.
        """
        scheduler = self._scheduler
        if scheduler is None:  # pragma: no cover - serve() always follows bind()
            raise RuntimeError("serve() called before bind(); scheduler is not initialized")
        await scheduler.start_in_background()
        logger.info("Scheduler started", extra={"jobs_count": len(self._registry)})
        await stop.wait()

    async def drain(self, grace: float) -> None:
        """Pause every schedule, then let in-flight jobs finish within ``grace``.

        No NEW jobs fire once the schedules are paused, but jobs already running
        keep going until they finish or ``grace`` elapses. This intentionally
        does NOT call ``AsyncScheduler.stop()``: in APScheduler 4.0.0a6 ``stop()``
        cancels the scheduler's cancel scope and hard-cancels in-flight jobs with
        zero grace. Teardown happens later, in :meth:`stop`.
        """
        scheduler = self._scheduler
        if scheduler is None or self._stopped or scheduler.state is not RunState.started:
            return

        await self._pause_all_schedules(scheduler)
        if await self._wait_for_running_jobs(scheduler, grace):
            return
        logger.warning(
            "Scheduler drain timed out with jobs still in flight",
            extra={"grace": grace, "running_jobs": len(scheduler._running_jobs)},
        )

    async def stop(self) -> None:
        """Tear the scheduler down (idempotent; safe before bind / after stop).

        This is the only place the scheduler is shut down: closing the held exit
        stack runs ``AsyncScheduler.__aexit__``, which cancels the scheduler's
        cancel scope and releases its services task group.
        """
        if self._stopped:
            return
        self._stopped = True
        stack = self._stack
        self._stack = None
        self._scheduler = None
        if stack is not None:
            await stack.aclose()

    @staticmethod
    async def _pause_all_schedules(scheduler: AsyncScheduler) -> None:
        for schedule in await scheduler.get_schedules():
            await scheduler.pause_schedule(schedule.id)

    @staticmethod
    async def _wait_for_running_jobs(scheduler: AsyncScheduler, grace: float) -> bool:
        """Poll the in-flight job set until empty or ``grace`` elapses.

        Returns ``True`` if every running job finished within ``grace``.
        """
        loop = asyncio.get_running_loop()
        deadline = loop.time() + grace
        while scheduler._running_jobs:
            if loop.time() >= deadline:
                return False
            await asyncio.sleep(_DRAIN_POLL_INTERVAL_SECONDS)
        return True

    async def _register_schedule(self, scheduler: AsyncScheduler, job: ScheduledJob) -> None:
        # Register a DISTINCT task per job, keyed by the job id, all backed by
        # the shared :meth:`_dispatch` target. In APScheduler v4 every schedule
        # on the same callable would otherwise collapse onto a single task, so
        # per-job concurrency (``max_running_jobs``) could not differ per job.
        task_options: dict[str, Any] = {"func": self._dispatch}
        if job.max_instances is not None:
            task_options["max_running_jobs"] = job.max_instances
        await scheduler.configure_task(job.id, **task_options)

        options: dict[str, Any] = {"id": job.id, "args": (job.id,)}
        if job.misfire_grace_time is not None:
            options["misfire_grace_time"] = job.misfire_grace_time
        if job.coalesce is not None:
            options["coalesce"] = job.coalesce

        # Schedule by the job-id task (a string), not a closure, so the schedule
        # stays APScheduler-serializable.
        await scheduler.add_schedule(job.id, job.trigger, **options)

    async def _dispatch(self, job_id: str) -> None:
        """Run one scheduled job inside a fresh per-job unit scope.

        This is the schedule target. It looks the job up in the registry and
        opens ``unit_scope(context={"job_id", "run_id"})`` around the call,
        carrying the prototype's structured logging + duration tracking.
        """
        job = self._registry.get(job_id)
        if job is None:  # pragma: no cover - registry is authoritative for added schedules
            logger.error("Scheduled job id not found in registry", extra={"job_id": job_id})
            return

        run_id = str(uuid4())
        log_ctx = {"job_id": job_id, "run_id": run_id}
        start = time.perf_counter()
        logger.info("Job execution started", extra=log_ctx)
        try:
            async with self.unit_scope(context={"job_id": job_id, "run_id": run_id}) as scope:
                await job.func(scope, *job.args, **job.kwargs)
        except asyncio.CancelledError:
            logger.warning(
                "Job execution cancelled",
                extra={**log_ctx, "duration_seconds": round(time.perf_counter() - start, 4)},
            )
            raise
        except Exception:
            # A failed job is logged but MUST NOT crash the scheduler loop.
            logger.exception(
                "Job execution failed",
                extra={**log_ctx, "duration_seconds": round(time.perf_counter() - start, 4)},
            )
            return
        logger.info(
            "Job execution completed",
            extra={**log_ctx, "duration_seconds": round(time.perf_counter() - start, 4)},
        )

    @staticmethod
    def _build_registry(jobs: Sequence[ScheduledJob]) -> dict[str, ScheduledJob]:
        ids = [job.id for job in jobs]
        if len(ids) != len(set(ids)):
            duplicates = sorted({id_ for id_ in ids if ids.count(id_) > 1})
            raise DuplicateScheduleError(duplicates)
        return {job.id: job for job in jobs}

jobs property

The configured scheduled jobs.

bind(ctx) async

Capture the container, enter the scheduler, and register schedules.

The :class:AsyncScheduler is entered here (not in :meth:serve) so it outlives serve() and is still alive when the Host calls :meth:drain / :meth:stop. The scheduler is held open on :attr:_stack; it is torn down only in :meth:stop.

Raises:

Type Description
DuplicateScheduleError

If two jobs share the same id.

Source code in servicewright/adapters/apscheduler4/entrypoint.py
async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
    """Capture the container, enter the scheduler, and register schedules.

    The :class:`AsyncScheduler` is entered here (not in :meth:`serve`) so it
    outlives ``serve()`` and is still alive when the Host calls
    :meth:`drain` / :meth:`stop`. The scheduler is held open on
    :attr:`_stack`; it is torn down only in :meth:`stop`.

    Raises:
        DuplicateScheduleError: If two jobs share the same ``id``.
    """
    await super().bind(ctx)
    self._registry = self._build_registry(self._jobs)

    stack = contextlib.AsyncExitStack()
    try:
        scheduler = await stack.enter_async_context(AsyncScheduler())
        for job in self._registry.values():
            await self._register_schedule(scheduler, job)
    except BaseException:
        await stack.aclose()
        raise
    self._scheduler = scheduler
    self._stack = stack

    logger.info(
        "Scheduler entrypoint bound",
        extra={"service": ctx.service_name, "jobs_count": len(self._registry)},
    )

drain(grace) async

Pause every schedule, then let in-flight jobs finish within grace.

No NEW jobs fire once the schedules are paused, but jobs already running keep going until they finish or grace elapses. This intentionally does NOT call AsyncScheduler.stop(): in APScheduler 4.0.0a6 stop() cancels the scheduler's cancel scope and hard-cancels in-flight jobs with zero grace. Teardown happens later, in :meth:stop.

Source code in servicewright/adapters/apscheduler4/entrypoint.py
async def drain(self, grace: float) -> None:
    """Pause every schedule, then let in-flight jobs finish within ``grace``.

    No NEW jobs fire once the schedules are paused, but jobs already running
    keep going until they finish or ``grace`` elapses. This intentionally
    does NOT call ``AsyncScheduler.stop()``: in APScheduler 4.0.0a6 ``stop()``
    cancels the scheduler's cancel scope and hard-cancels in-flight jobs with
    zero grace. Teardown happens later, in :meth:`stop`.
    """
    scheduler = self._scheduler
    if scheduler is None or self._stopped or scheduler.state is not RunState.started:
        return

    await self._pause_all_schedules(scheduler)
    if await self._wait_for_running_jobs(scheduler, grace):
        return
    logger.warning(
        "Scheduler drain timed out with jobs still in flight",
        extra={"grace": grace, "running_jobs": len(scheduler._running_jobs)},
    )

serve(*, stop) async

Start the scheduler and run until the host's stop event is set.

This only starts the (already-entered) scheduler in the background and waits on stop. It deliberately does NOT close or null the scheduler: the Host calls :meth:drain / :meth:stop AFTER serve() returns and needs a live scheduler to drain.

Source code in servicewright/adapters/apscheduler4/entrypoint.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Start the scheduler and run until the host's ``stop`` event is set.

    This only starts the (already-entered) scheduler in the background and
    waits on ``stop``. It deliberately does NOT close or null the scheduler:
    the Host calls :meth:`drain` / :meth:`stop` AFTER ``serve()`` returns and
    needs a live scheduler to drain.
    """
    scheduler = self._scheduler
    if scheduler is None:  # pragma: no cover - serve() always follows bind()
        raise RuntimeError("serve() called before bind(); scheduler is not initialized")
    await scheduler.start_in_background()
    logger.info("Scheduler started", extra={"jobs_count": len(self._registry)})
    await stop.wait()

stop() async

Tear the scheduler down (idempotent; safe before bind / after stop).

This is the only place the scheduler is shut down: closing the held exit stack runs AsyncScheduler.__aexit__, which cancels the scheduler's cancel scope and releases its services task group.

Source code in servicewright/adapters/apscheduler4/entrypoint.py
async def stop(self) -> None:
    """Tear the scheduler down (idempotent; safe before bind / after stop).

    This is the only place the scheduler is shut down: closing the held exit
    stack runs ``AsyncScheduler.__aexit__``, which cancels the scheduler's
    cancel scope and releases its services task group.
    """
    if self._stopped:
        return
    self._stopped = True
    stack = self._stack
    self._stack = None
    self._scheduler = None
    if stack is not None:
        await stack.aclose()

unit_scope(context=None)

Open a per-unit-of-work DI scope.

Raises:

Type Description
RuntimeError

If called before :meth:bind.

Source code in servicewright/core/contracts/bases.py
def unit_scope(
    self, context: Mapping[Any, Any] | None = None
) -> contextlib.AbstractAsyncContextManager[UnitScopeProtocol]:
    """Open a per-unit-of-work DI scope.

    Raises:
        RuntimeError: If called before :meth:`bind`.
    """
    if self._container is None:
        raise RuntimeError("unit_scope() called before bind(); entrypoint is not bound to a container")
    return self._container.unit_scope(context)

SchedulerError

Bases: Exception

Base exception for the scheduler entrypoint.

Source code in servicewright/adapters/apscheduler4/exceptions.py
class SchedulerError(Exception):
    """Base exception for the scheduler entrypoint."""

SchedulerPlugin

Declarative wiring: register a :class:SchedulerEntrypoint on the host.

Pass the same arguments as :class:SchedulerEntrypoint; on_register builds it and adds it to the host.

Source code in servicewright/adapters/apscheduler4/entrypoint.py
class SchedulerPlugin:
    """Declarative wiring: register a :class:`SchedulerEntrypoint` on the host.

    Pass the same arguments as :class:`SchedulerEntrypoint`; ``on_register``
    builds it and adds it to the host.
    """

    def __init__(
        self,
        *,
        jobs: Sequence[ScheduledJob],
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        self._entrypoint = SchedulerEntrypoint(jobs=jobs, kind=kind, essential=essential)

    @property
    def entrypoint(self) -> SchedulerEntrypoint:
        """The entrypoint that will be registered on the host."""
        return self._entrypoint

    def on_register(self, spec: Any, host: Any) -> None:
        """Append the scheduler entrypoint to the host."""
        host.add_entrypoint(self._entrypoint)

entrypoint property

The entrypoint that will be registered on the host.

on_register(spec, host)

Append the scheduler entrypoint to the host.

Source code in servicewright/adapters/apscheduler4/entrypoint.py
def on_register(self, spec: Any, host: Any) -> None:
    """Append the scheduler entrypoint to the host."""
    host.add_entrypoint(self._entrypoint)

adapters.apscheduler3

The public surface is identical to apscheduler4; only ScheduledJob.coalesce differs (bool instead of CoalescePolicy).

APScheduler 3.x scheduler entrypoint adapter ([apscheduler3] extra).

Public surface is identical to the apscheduler4 adapter (enforced by an AST conformance test); only the implementation differs, against APScheduler 3.x.

DuplicateScheduleError

Bases: SchedulerError

Raised when two or more scheduled jobs share the same id.

Source code in servicewright/adapters/apscheduler3/exceptions.py
class DuplicateScheduleError(SchedulerError):
    """Raised when two or more scheduled jobs share the same ``id``."""

    def __init__(self, duplicates: list[str]) -> None:
        self.duplicates = duplicates
        super().__init__(f"Duplicate schedule IDs detected: {duplicates}")

ScheduledJob dataclass

Description of a single scheduled job (APScheduler 3.x).

Attributes:

Name Type Description
id str

Unique identifier for the job (also the APScheduler job id).

func Callable[[UnitScopeProtocol], Awaitable[None]]

Async callable invoked as func(scope, *args, **kwargs) where scope is the per-job :class:UnitScopeProtocol.

trigger Trigger

APScheduler 3.x :class:~apscheduler.triggers.base.BaseTrigger.

args Sequence[Any]

Positional arguments passed after scope.

kwargs Mapping[str, Any]

Keyword arguments passed to func.

max_instances int | None

Maximum number of concurrent running instances for this job (maps to APScheduler 3.x add_job(max_instances=...)).

misfire_grace_time float | None

Seconds after which a misfired run is skipped.

coalesce bool | None

Whether missed runs are collapsed into one (APScheduler 3.x add_job(coalesce=...), a plain bool).

Source code in servicewright/adapters/apscheduler3/config.py
@dataclass(frozen=True, slots=True)
class ScheduledJob:
    """Description of a single scheduled job (APScheduler 3.x).

    Attributes:
        id: Unique identifier for the job (also the APScheduler job id).
        func: Async callable invoked as ``func(scope, *args, **kwargs)`` where
            ``scope`` is the per-job :class:`UnitScopeProtocol`.
        trigger: APScheduler 3.x :class:`~apscheduler.triggers.base.BaseTrigger`.
        args: Positional arguments passed after ``scope``.
        kwargs: Keyword arguments passed to ``func``.
        max_instances: Maximum number of concurrent running instances for this
            job (maps to APScheduler 3.x ``add_job(max_instances=...)``).
        misfire_grace_time: Seconds after which a misfired run is skipped.
        coalesce: Whether missed runs are collapsed into one (APScheduler 3.x
            ``add_job(coalesce=...)``, a plain ``bool``).
    """

    id: str
    func: Callable[[UnitScopeProtocol], Awaitable[None]]
    trigger: Trigger
    args: Sequence[Any] = ()
    kwargs: Mapping[str, Any] = field(default_factory=dict)
    max_instances: int | None = None
    misfire_grace_time: float | None = None
    coalesce: bool | None = None

SchedulerEntrypoint

Bases: ScopedEntrypoint

An APScheduler 3.x-driven scheduler entrypoint driven by the :class:Host.

Parameters:

Name Type Description Default
jobs Sequence[ScheduledJob]

The scheduled jobs to register. Each fires inside a fresh per-job :class:~servicewright.core.contracts.UnitScopeProtocol.

required
kind str

Telemetry label (default "scheduler").

'scheduler'
essential bool

Whether the entrypoint's exit/failure stops the process.

True
Source code in servicewright/adapters/apscheduler3/entrypoint.py
class SchedulerEntrypoint(ScopedEntrypoint):
    """An APScheduler 3.x-driven scheduler entrypoint driven by the :class:`Host`.

    Args:
        jobs: The scheduled jobs to register. Each fires inside a fresh per-job
            :class:`~servicewright.core.contracts.UnitScopeProtocol`.
        kind: Telemetry label (default ``"scheduler"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        jobs: Sequence[ScheduledJob],
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        super().__init__()
        self._jobs: list[ScheduledJob] = list(jobs)
        self.kind = kind
        self.essential = essential

        self._registry: dict[str, ScheduledJob] = {}
        self._scheduler: AsyncIOScheduler | None = None
        self._running_jobs: set[str] = set()
        self._stopped = False

    @property
    def jobs(self) -> list[ScheduledJob]:
        """The configured scheduled jobs."""
        return self._jobs

    async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
        """Capture the container, build the scheduler, and register jobs.

        The scheduler is created and the jobs are added here, but it is not
        started until :meth:`serve`.

        Raises:
            DuplicateScheduleError: If two jobs share the same ``id``.
        """
        await super().bind(ctx)
        self._registry = self._build_registry(self._jobs)

        scheduler = AsyncIOScheduler()
        for job in self._registry.values():
            self._register_job(scheduler, job)
        self._scheduler = scheduler

        logger.info(
            "Scheduler entrypoint bound",
            extra={"service": ctx.service_name, "jobs_count": len(self._registry)},
        )

    async def serve(self, *, stop: asyncio.Event) -> None:
        """Start the scheduler and run until the host's ``stop`` event is set."""
        scheduler = self._scheduler
        if scheduler is None:  # pragma: no cover - serve() always follows bind()
            raise RuntimeError("serve() called before bind(); scheduler is not initialized")
        scheduler.start()
        logger.info("Scheduler started", extra={"jobs_count": len(self._registry)})
        await stop.wait()

    async def drain(self, grace: float) -> None:
        """Pause new job runs and let in-flight ones finish within ``grace``.

        APScheduler 3.x exposes no in-flight-job set of its own — and its
        ``AsyncIOExecutor.shutdown`` cancels every pending future regardless of
        ``wait`` — so this adapter tracks its own runs in :meth:`_dispatch`.
        Without that, ``stop()`` would cancel a running job mid-transaction and
        the whole grace window would be silently discarded, which is exactly the
        behaviour the v4 sibling avoids.
        """
        scheduler = self._scheduler
        if scheduler is None or self._stopped or not scheduler.running:
            return
        scheduler.pause()
        if not await self._wait_for_running_jobs(grace):
            logger.warning(
                "Scheduler drain timed out with jobs still running",
                extra={"grace": grace, "running_jobs": len(self._running_jobs)},
            )

    async def _wait_for_running_jobs(self, grace: float) -> bool:
        """Poll the in-flight run set until empty or ``grace`` elapses."""
        loop = asyncio.get_running_loop()
        deadline = loop.time() + grace
        while self._running_jobs:
            if loop.time() >= deadline:
                return False
            await asyncio.sleep(_DRAIN_POLL_INTERVAL_SECONDS)
        return True

    async def stop(self) -> None:
        """Shut the scheduler down (idempotent; safe before bind / after stop)."""
        if self._stopped:
            return
        self._stopped = True
        scheduler = self._scheduler
        self._scheduler = None
        if scheduler is not None and scheduler.running:
            scheduler.shutdown(wait=False)

    def _register_job(self, scheduler: AsyncIOScheduler, job: ScheduledJob) -> None:
        options: dict[str, Any] = {"id": job.id, "args": (job.id,)}
        if job.max_instances is not None:
            options["max_instances"] = job.max_instances
        if job.misfire_grace_time is not None:
            options["misfire_grace_time"] = job.misfire_grace_time
        if job.coalesce is not None:
            options["coalesce"] = job.coalesce
        scheduler.add_job(self._dispatch, job.trigger, **options)

    async def _dispatch(self, job_id: str) -> None:
        """Run one scheduled job inside a fresh per-job unit scope."""
        job = self._registry.get(job_id)
        if job is None:  # pragma: no cover - registry is authoritative for added jobs
            logger.error("Scheduled job id not found in registry", extra={"job_id": job_id})
            return

        run_id = str(uuid4())
        log_ctx = {"job_id": job_id, "run_id": run_id}
        start = time.perf_counter()
        logger.info("Job execution started", extra=log_ctx)
        self._running_jobs.add(run_id)
        try:
            async with self.unit_scope(context={"job_id": job_id, "run_id": run_id}) as scope:
                await job.func(scope, *job.args, **job.kwargs)
        except asyncio.CancelledError:
            logger.warning(
                "Job execution cancelled",
                extra={**log_ctx, "duration_seconds": round(time.perf_counter() - start, 4)},
            )
            raise
        except Exception:
            logger.exception(
                "Job execution failed",
                extra={**log_ctx, "duration_seconds": round(time.perf_counter() - start, 4)},
            )
            return
        finally:
            self._running_jobs.discard(run_id)
        logger.info(
            "Job execution completed",
            extra={**log_ctx, "duration_seconds": round(time.perf_counter() - start, 4)},
        )

    @staticmethod
    def _build_registry(jobs: Sequence[ScheduledJob]) -> dict[str, ScheduledJob]:
        ids = [job.id for job in jobs]
        if len(ids) != len(set(ids)):
            duplicates = sorted({id_ for id_ in ids if ids.count(id_) > 1})
            raise DuplicateScheduleError(duplicates)
        return {job.id: job for job in jobs}

jobs property

The configured scheduled jobs.

bind(ctx) async

Capture the container, build the scheduler, and register jobs.

The scheduler is created and the jobs are added here, but it is not started until :meth:serve.

Raises:

Type Description
DuplicateScheduleError

If two jobs share the same id.

Source code in servicewright/adapters/apscheduler3/entrypoint.py
async def bind(self, ctx: ServiceContext[Any, Any]) -> None:
    """Capture the container, build the scheduler, and register jobs.

    The scheduler is created and the jobs are added here, but it is not
    started until :meth:`serve`.

    Raises:
        DuplicateScheduleError: If two jobs share the same ``id``.
    """
    await super().bind(ctx)
    self._registry = self._build_registry(self._jobs)

    scheduler = AsyncIOScheduler()
    for job in self._registry.values():
        self._register_job(scheduler, job)
    self._scheduler = scheduler

    logger.info(
        "Scheduler entrypoint bound",
        extra={"service": ctx.service_name, "jobs_count": len(self._registry)},
    )

drain(grace) async

Pause new job runs and let in-flight ones finish within grace.

APScheduler 3.x exposes no in-flight-job set of its own — and its AsyncIOExecutor.shutdown cancels every pending future regardless of wait — so this adapter tracks its own runs in :meth:_dispatch. Without that, stop() would cancel a running job mid-transaction and the whole grace window would be silently discarded, which is exactly the behaviour the v4 sibling avoids.

Source code in servicewright/adapters/apscheduler3/entrypoint.py
async def drain(self, grace: float) -> None:
    """Pause new job runs and let in-flight ones finish within ``grace``.

    APScheduler 3.x exposes no in-flight-job set of its own — and its
    ``AsyncIOExecutor.shutdown`` cancels every pending future regardless of
    ``wait`` — so this adapter tracks its own runs in :meth:`_dispatch`.
    Without that, ``stop()`` would cancel a running job mid-transaction and
    the whole grace window would be silently discarded, which is exactly the
    behaviour the v4 sibling avoids.
    """
    scheduler = self._scheduler
    if scheduler is None or self._stopped or not scheduler.running:
        return
    scheduler.pause()
    if not await self._wait_for_running_jobs(grace):
        logger.warning(
            "Scheduler drain timed out with jobs still running",
            extra={"grace": grace, "running_jobs": len(self._running_jobs)},
        )

serve(*, stop) async

Start the scheduler and run until the host's stop event is set.

Source code in servicewright/adapters/apscheduler3/entrypoint.py
async def serve(self, *, stop: asyncio.Event) -> None:
    """Start the scheduler and run until the host's ``stop`` event is set."""
    scheduler = self._scheduler
    if scheduler is None:  # pragma: no cover - serve() always follows bind()
        raise RuntimeError("serve() called before bind(); scheduler is not initialized")
    scheduler.start()
    logger.info("Scheduler started", extra={"jobs_count": len(self._registry)})
    await stop.wait()

stop() async

Shut the scheduler down (idempotent; safe before bind / after stop).

Source code in servicewright/adapters/apscheduler3/entrypoint.py
async def stop(self) -> None:
    """Shut the scheduler down (idempotent; safe before bind / after stop)."""
    if self._stopped:
        return
    self._stopped = True
    scheduler = self._scheduler
    self._scheduler = None
    if scheduler is not None and scheduler.running:
        scheduler.shutdown(wait=False)

unit_scope(context=None)

Open a per-unit-of-work DI scope.

Raises:

Type Description
RuntimeError

If called before :meth:bind.

Source code in servicewright/core/contracts/bases.py
def unit_scope(
    self, context: Mapping[Any, Any] | None = None
) -> contextlib.AbstractAsyncContextManager[UnitScopeProtocol]:
    """Open a per-unit-of-work DI scope.

    Raises:
        RuntimeError: If called before :meth:`bind`.
    """
    if self._container is None:
        raise RuntimeError("unit_scope() called before bind(); entrypoint is not bound to a container")
    return self._container.unit_scope(context)

SchedulerError

Bases: Exception

Base exception for the scheduler entrypoint.

Source code in servicewright/adapters/apscheduler3/exceptions.py
class SchedulerError(Exception):
    """Base exception for the scheduler entrypoint."""

SchedulerPlugin

Declarative wiring: register a :class:SchedulerEntrypoint on the host.

Source code in servicewright/adapters/apscheduler3/entrypoint.py
class SchedulerPlugin:
    """Declarative wiring: register a :class:`SchedulerEntrypoint` on the host."""

    def __init__(
        self,
        *,
        jobs: Sequence[ScheduledJob],
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        self._entrypoint = SchedulerEntrypoint(jobs=jobs, kind=kind, essential=essential)

    @property
    def entrypoint(self) -> SchedulerEntrypoint:
        """The entrypoint that will be registered on the host."""
        return self._entrypoint

    def on_register(self, spec: Any, host: Any) -> None:
        """Append the scheduler entrypoint to the host."""
        host.add_entrypoint(self._entrypoint)

entrypoint property

The entrypoint that will be registered on the host.

on_register(spec, host)

Append the scheduler entrypoint to the host.

Source code in servicewright/adapters/apscheduler3/entrypoint.py
def on_register(self, spec: Any, host: Any) -> None:
    """Append the scheduler entrypoint to the host."""
    host.add_entrypoint(self._entrypoint)

adapters.dishka

Dishka DI binding ([dishka] extra).

Maps servicewright's two-tier scope model onto dishka's Scope.APP / Scope.REQUEST. Importing this package requires servicewright[dishka].

DishkaContainer

Adapt a dishka AsyncContainer to :class:DependencyContainerProtocol.

Parameters:

Name Type Description Default
container AsyncContainer

The APP-scoped AsyncContainer returned by dishka's make_async_container(...).

required
Source code in servicewright/adapters/dishka/container.py
class DishkaContainer:
    """Adapt a dishka ``AsyncContainer`` to :class:`DependencyContainerProtocol`.

    Args:
        container: The APP-scoped ``AsyncContainer`` returned by dishka's
            ``make_async_container(...)``.
    """

    def __init__(self, container: AsyncContainer) -> None:
        self._container = container

    @property
    def container(self) -> AsyncContainer:
        """The underlying APP-scoped dishka container."""
        return self._container

    @contextlib.asynccontextmanager
    async def app_scope(self) -> AsyncIterator[DishkaScope]:
        """Yield the APP scope; closing it finalizes APP-scoped dependencies.

        The dishka container is already at ``Scope.APP`` after
        ``make_async_container``; this context manager simply guarantees that
        ``container.close()`` runs on exit (the Host closes the app scope last).
        """
        try:
            yield DishkaScope(self._container)
        finally:
            await self._container.close()

    @contextlib.asynccontextmanager
    async def unit_scope(self, context: Mapping[Any, Any] | None = None) -> AsyncIterator[DishkaScope]:
        """Enter dishka's ``Scope.REQUEST`` carrying ``context`` as request data.

        Exiting the ``async with`` closes the REQUEST scope, letting dishka
        finalize every REQUEST-scoped dependency.

        Raises:
            RuntimeError: If the request in ``context`` already carries a dishka
                request container — dishka's own framework integration
                (``setup_dishka``) is installed next to servicewright's
                per-request middleware, which would open two REQUEST scopes
                per request.
        """
        _reject_double_open(context)
        request_context = dict(context) if context is not None else None
        async with self._container(context=request_context) as request_container:
            yield DishkaScope(request_container)

container property

The underlying APP-scoped dishka container.

app_scope() async

Yield the APP scope; closing it finalizes APP-scoped dependencies.

The dishka container is already at Scope.APP after make_async_container; this context manager simply guarantees that container.close() runs on exit (the Host closes the app scope last).

Source code in servicewright/adapters/dishka/container.py
@contextlib.asynccontextmanager
async def app_scope(self) -> AsyncIterator[DishkaScope]:
    """Yield the APP scope; closing it finalizes APP-scoped dependencies.

    The dishka container is already at ``Scope.APP`` after
    ``make_async_container``; this context manager simply guarantees that
    ``container.close()`` runs on exit (the Host closes the app scope last).
    """
    try:
        yield DishkaScope(self._container)
    finally:
        await self._container.close()

unit_scope(context=None) async

Enter dishka's Scope.REQUEST carrying context as request data.

Exiting the async with closes the REQUEST scope, letting dishka finalize every REQUEST-scoped dependency.

Raises:

Type Description
RuntimeError

If the request in context already carries a dishka request container — dishka's own framework integration (setup_dishka) is installed next to servicewright's per-request middleware, which would open two REQUEST scopes per request.

Source code in servicewright/adapters/dishka/container.py
@contextlib.asynccontextmanager
async def unit_scope(self, context: Mapping[Any, Any] | None = None) -> AsyncIterator[DishkaScope]:
    """Enter dishka's ``Scope.REQUEST`` carrying ``context`` as request data.

    Exiting the ``async with`` closes the REQUEST scope, letting dishka
    finalize every REQUEST-scoped dependency.

    Raises:
        RuntimeError: If the request in ``context`` already carries a dishka
            request container — dishka's own framework integration
            (``setup_dishka``) is installed next to servicewright's
            per-request middleware, which would open two REQUEST scopes
            per request.
    """
    _reject_double_open(context)
    request_context = dict(context) if context is not None else None
    async with self._container(context=request_context) as request_container:
        yield DishkaScope(request_container)

DishkaScope

Thin wrapper over a dishka AsyncContainer exposing get.

Satisfies both :class:~servicewright.core.contracts.AppScopeProtocol and :class:~servicewright.core.contracts.UnitScopeProtocol: get resolves a dependency by type or string key, delegating to the wrapped container.

Source code in servicewright/adapters/dishka/container.py
class DishkaScope:
    """Thin wrapper over a dishka ``AsyncContainer`` exposing ``get``.

    Satisfies both :class:`~servicewright.core.contracts.AppScopeProtocol` and
    :class:`~servicewright.core.contracts.UnitScopeProtocol`: ``get`` resolves a
    dependency by type or string key, delegating to the wrapped container.
    """

    def __init__(self, container: AsyncContainer) -> None:
        self._container = container

    @property
    def container(self) -> AsyncContainer:
        """The wrapped dishka container at this scope."""
        return self._container

    @overload
    async def get(self, dependency_key: type[T]) -> T: ...

    @overload
    async def get(self, dependency_key: str) -> Any: ...

    async def get(self, dependency_key: type[T] | str) -> T | Any:
        """Resolve a dependency by type or string key from this dishka scope."""
        return await self._container.get(dependency_key)

container property

The wrapped dishka container at this scope.

get(dependency_key) async

get(dependency_key: type[T]) -> T
get(dependency_key: str) -> Any

Resolve a dependency by type or string key from this dishka scope.

Source code in servicewright/adapters/dishka/container.py
async def get(self, dependency_key: type[T] | str) -> T | Any:
    """Resolve a dependency by type or string key from this dishka scope."""
    return await self._container.get(dependency_key)

adapters.observability

The author-facing sink ABCs. Concrete backends are resolved lazily through the registry.

Pluggable observability add-on adapters (extra-gated implementation layer).

Side-effect-free: importing this package pulls in no SDK. The author-facing sink ABCs are re-exported here from :mod:base; concrete backends live in _metrics / _tracing / _errors / _logging (each behind its own extra and import guard) and are resolved lazily through :mod:servicewright.core.observability.registry. The runtime seams they implement are defined in :mod:servicewright.core.contracts.observability.

Backends are transport-neutral: they expose generic instruments (counter / histogram); transport adapters own their metric names and recorders.

ErrorTrackingSink

Bases: ABC

An error-tracking backend (sentry).

Source code in servicewright/adapters/observability/base.py
class ErrorTrackingSink(ABC):
    """An error-tracking backend (sentry)."""

    backend: str

    @abstractmethod
    def setup(self, ctx: ObsSetupContext) -> None:
        """Initialize the global error-tracking client."""
        ...

    @abstractmethod
    def shutdown(self) -> None:
        """Flush queued events (best-effort)."""
        ...

    @abstractmethod
    def reporter(self) -> ErrorReporterProtocol:
        """Mint the error-reporter seam."""
        ...

reporter() abstractmethod

Mint the error-reporter seam.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def reporter(self) -> ErrorReporterProtocol:
    """Mint the error-reporter seam."""
    ...

setup(ctx) abstractmethod

Initialize the global error-tracking client.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def setup(self, ctx: ObsSetupContext) -> None:
    """Initialize the global error-tracking client."""
    ...

shutdown() abstractmethod

Flush queued events (best-effort).

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def shutdown(self) -> None:
    """Flush queued events (best-effort)."""
    ...

LoggingSink

Bases: ABC

A logging backend (structlog / stdlib).

Source code in servicewright/adapters/observability/base.py
class LoggingSink(ABC):
    """A logging backend (structlog / stdlib)."""

    backend: str

    @abstractmethod
    def setup(self, ctx: ObsSetupContext) -> None:
        """Configure the root logger / processor chain."""
        ...

    @abstractmethod
    def shutdown(self) -> None:
        """Tear down logging handlers (best-effort)."""
        ...

setup(ctx) abstractmethod

Configure the root logger / processor chain.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def setup(self, ctx: ObsSetupContext) -> None:
    """Configure the root logger / processor chain."""
    ...

shutdown() abstractmethod

Tear down logging handlers (best-effort).

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def shutdown(self) -> None:
    """Tear down logging handlers (best-effort)."""
    ...

MetricsSink

Bases: ABC

A metrics backend (prometheus / datadog / otel).

A transport-neutral instrument factory: transport adapters compose their recorders (and own their frozen metric names) from these instruments. Repeated requests for the same name must return the same instrument.

Source code in servicewright/adapters/observability/base.py
class MetricsSink(ABC):
    """A metrics backend (prometheus / datadog / otel).

    A transport-neutral instrument factory: transport adapters compose their
    recorders (and own their frozen metric names) from these instruments.
    Repeated requests for the same ``name`` must return the same instrument.
    """

    backend: str

    @abstractmethod
    def setup(self, ctx: ObsSetupContext) -> None:
        """Initialize the client/registry; start exposition if settings enable it."""
        ...

    @abstractmethod
    def shutdown(self) -> None:
        """Flush/close the client and stop exposition (best-effort)."""
        ...

    @abstractmethod
    def counter(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> CounterProtocol:
        """Mint (or reuse) a counter instrument."""
        ...

    @abstractmethod
    def histogram(
        self,
        name: str,
        description: str,
        label_names: tuple[str, ...] = (),
        buckets: tuple[float, ...] | None = None,
    ) -> HistogramProtocol:
        """Mint (or reuse) a histogram instrument (``None`` buckets = backend default)."""
        ...

counter(name, description, label_names=()) abstractmethod

Mint (or reuse) a counter instrument.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def counter(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> CounterProtocol:
    """Mint (or reuse) a counter instrument."""
    ...

histogram(name, description, label_names=(), buckets=None) abstractmethod

Mint (or reuse) a histogram instrument (None buckets = backend default).

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def histogram(
    self,
    name: str,
    description: str,
    label_names: tuple[str, ...] = (),
    buckets: tuple[float, ...] | None = None,
) -> HistogramProtocol:
    """Mint (or reuse) a histogram instrument (``None`` buckets = backend default)."""
    ...

setup(ctx) abstractmethod

Initialize the client/registry; start exposition if settings enable it.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def setup(self, ctx: ObsSetupContext) -> None:
    """Initialize the client/registry; start exposition if settings enable it."""
    ...

shutdown() abstractmethod

Flush/close the client and stop exposition (best-effort).

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def shutdown(self) -> None:
    """Flush/close the client and stop exposition (best-effort)."""
    ...

TracingSink

Bases: ABC

A tracing backend (otel / datadog).

Source code in servicewright/adapters/observability/base.py
class TracingSink(ABC):
    """A tracing backend (otel / datadog)."""

    backend: str

    @abstractmethod
    def setup(self, ctx: ObsSetupContext) -> None:
        """Install the global tracer provider + exporter + sampler."""
        ...

    @abstractmethod
    def shutdown(self) -> None:
        """Flush spans (best-effort)."""
        ...

    @abstractmethod
    def tracer(self, name: str) -> TracerProtocol:
        """Mint a tracer seam."""
        ...

    def instrument_fastapi(self, app: Any, *, excluded_urls: str | None = None) -> None:  # noqa: B027 - optional hook
        """Instrument a live FastAPI app (needs the app instance, so done at bind)."""

instrument_fastapi(app, *, excluded_urls=None)

Instrument a live FastAPI app (needs the app instance, so done at bind).

Source code in servicewright/adapters/observability/base.py
def instrument_fastapi(self, app: Any, *, excluded_urls: str | None = None) -> None:  # noqa: B027 - optional hook
    """Instrument a live FastAPI app (needs the app instance, so done at bind)."""

setup(ctx) abstractmethod

Install the global tracer provider + exporter + sampler.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def setup(self, ctx: ObsSetupContext) -> None:
    """Install the global tracer provider + exporter + sampler."""
    ...

shutdown() abstractmethod

Flush spans (best-effort).

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def shutdown(self) -> None:
    """Flush spans (best-effort)."""
    ...

tracer(name) abstractmethod

Mint a tracer seam.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def tracer(self, name: str) -> TracerProtocol:
    """Mint a tracer seam."""
    ...

adapters.warmers

Bases: AsyncWarmer

Warmer for Postgres connection pool.

Executes a simple query (default SELECT 1) to ensure connections are established and the database is responsive.

Source code in servicewright/adapters/warmers/postgres.py
class PostgresWarmer(AsyncWarmer):
    """Warmer for Postgres connection pool.

    Executes a simple query (default SELECT 1) to ensure connections are
    established and the database is responsive.
    """

    def __init__(
        self,
        session_manager: Any,
        query: str = DEFAULT_WARMUP_QUERY,
        timeout: float = DEFAULT_WARMUP_TIMEOUT,
        priority: int = 0,
        raise_on_failure: bool = True,
    ) -> None:
        """Initialize Postgres warmer.

        Args:
            session_manager: Async Postgres manager instance.
            query: SQL query to execute for warmup.
            timeout: Maximum time to wait for the warmup operation in seconds.
            priority: Execution priority (lower value means higher priority).
            raise_on_failure: Whether warmup failure should fail startup.

        Raises:
            ValueError: If timeout is not positive.
            PostgresWarmupError: If sqlalchemy is not installed.
        """
        if not SQLALCHEMY_AVAILABLE:
            raise PostgresWarmupError("sqlalchemy is required for Postgres warmup")

        if timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout}")

        super().__init__(raise_on_failure=raise_on_failure)
        self._session_manager = session_manager
        self._query = query
        self._timeout = timeout
        self._priority = priority

    @property
    def priority(self) -> int:
        """Get warmer priority."""
        return self._priority

    async def warmup(self) -> None:
        """Perform Postgres warmup.

        Raises:
            PostgresWarmupError: If warmup fails or times out.
        """
        logger.info("Warming up Postgres", extra={"query": self._query, "timeout": self._timeout})

        try:
            async with asyncio.timeout(self._timeout):
                await self._perform_warmup()
        except TimeoutError as e:
            logger.warning("Postgres warmup timed out", extra={"timeout": self._timeout})
            raise PostgresWarmupError(f"Postgres warmup timed out after {self._timeout}s") from e
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except SQLAlchemyError as e:
            logger.warning("Postgres warmup failed", extra={"error": str(e)})
            raise PostgresWarmupError(f"Postgres warmup failed: {e}") from e
        except Exception as e:
            if isinstance(e, PostgresWarmupError):
                raise
            logger.exception("Unexpected error during Postgres warmup", extra={"error": str(e)})
            raise PostgresWarmupError(f"Postgres warmup failed due to unexpected error: {e}") from e

        logger.info("Postgres warmup successful")

    async def _perform_warmup(self) -> None:
        """Internal implementation of Postgres warmup."""
        if text is None:
            raise PostgresWarmupError("sqlalchemy is required for Postgres warmup")

        async with self._session_manager.get_session() as session:
            await session.execute(text(self._query))

priority property

Get warmer priority.

raise_on_failure property

Whether orchestrator should raise when this warmer fails.

__init__(session_manager, query=DEFAULT_WARMUP_QUERY, timeout=DEFAULT_WARMUP_TIMEOUT, priority=0, raise_on_failure=True)

Initialize Postgres warmer.

Parameters:

Name Type Description Default
session_manager Any

Async Postgres manager instance.

required
query str

SQL query to execute for warmup.

DEFAULT_WARMUP_QUERY
timeout float

Maximum time to wait for the warmup operation in seconds.

DEFAULT_WARMUP_TIMEOUT
priority int

Execution priority (lower value means higher priority).

0
raise_on_failure bool

Whether warmup failure should fail startup.

True

Raises:

Type Description
ValueError

If timeout is not positive.

PostgresWarmupError

If sqlalchemy is not installed.

Source code in servicewright/adapters/warmers/postgres.py
def __init__(
    self,
    session_manager: Any,
    query: str = DEFAULT_WARMUP_QUERY,
    timeout: float = DEFAULT_WARMUP_TIMEOUT,
    priority: int = 0,
    raise_on_failure: bool = True,
) -> None:
    """Initialize Postgres warmer.

    Args:
        session_manager: Async Postgres manager instance.
        query: SQL query to execute for warmup.
        timeout: Maximum time to wait for the warmup operation in seconds.
        priority: Execution priority (lower value means higher priority).
        raise_on_failure: Whether warmup failure should fail startup.

    Raises:
        ValueError: If timeout is not positive.
        PostgresWarmupError: If sqlalchemy is not installed.
    """
    if not SQLALCHEMY_AVAILABLE:
        raise PostgresWarmupError("sqlalchemy is required for Postgres warmup")

    if timeout <= 0:
        raise ValueError(f"timeout must be positive, got {timeout}")

    super().__init__(raise_on_failure=raise_on_failure)
    self._session_manager = session_manager
    self._query = query
    self._timeout = timeout
    self._priority = priority

warmup() async

Perform Postgres warmup.

Raises:

Type Description
PostgresWarmupError

If warmup fails or times out.

Source code in servicewright/adapters/warmers/postgres.py
async def warmup(self) -> None:
    """Perform Postgres warmup.

    Raises:
        PostgresWarmupError: If warmup fails or times out.
    """
    logger.info("Warming up Postgres", extra={"query": self._query, "timeout": self._timeout})

    try:
        async with asyncio.timeout(self._timeout):
            await self._perform_warmup()
    except TimeoutError as e:
        logger.warning("Postgres warmup timed out", extra={"timeout": self._timeout})
        raise PostgresWarmupError(f"Postgres warmup timed out after {self._timeout}s") from e
    except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
        raise
    except SQLAlchemyError as e:
        logger.warning("Postgres warmup failed", extra={"error": str(e)})
        raise PostgresWarmupError(f"Postgres warmup failed: {e}") from e
    except Exception as e:
        if isinstance(e, PostgresWarmupError):
            raise
        logger.exception("Unexpected error during Postgres warmup", extra={"error": str(e)})
        raise PostgresWarmupError(f"Postgres warmup failed due to unexpected error: {e}") from e

    logger.info("Postgres warmup successful")

Bases: AsyncWarmer

Warmer for Redis connection pool.

Performs concurrent pings to fill the connection pool and verifies connectivity using an optional health check from redis-client-kit.

Source code in servicewright/adapters/warmers/redis.py
class RedisWarmer(AsyncWarmer):
    """Warmer for Redis connection pool.

    Performs concurrent pings to fill the connection pool and verifies
    connectivity using an optional health check from `redis-client-kit`.
    """

    def __init__(
        self,
        redis_client: RedisClientProtocol,
        max_connections: int | None = None,
        timeout: float = DEFAULT_WARMUP_TIMEOUT,
        priority: int = 0,
        raise_on_failure: bool = True,
    ) -> None:
        """Initialize Redis warmer.

        Args:
            redis_client: Async Redis client instance.
            max_connections: Number of concurrent pings to fill the pool.
                Defaults to DEFAULT_WARMUP_POOL_SIZE if None.
            timeout: Maximum time to wait for the warmup operation in seconds.
            priority: Execution priority (lower value means higher priority).
            raise_on_failure: Whether warmup failure should fail startup.

        Raises:
            ValueError: If max_connections (if provided) or timeout is not positive.
        """
        if max_connections is not None and max_connections <= 0:
            raise ValueError(f"max_connections must be positive, got {max_connections}")
        if timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout}")

        super().__init__(raise_on_failure=raise_on_failure)
        self._redis_client = redis_client
        self._max_connections = max_connections or DEFAULT_WARMUP_POOL_SIZE
        self._timeout = timeout
        self._priority = priority

    @property
    def priority(self) -> int:
        """Get warmer priority."""
        return self._priority

    async def warmup(self) -> None:
        """Perform Redis warmup.

        Raises:
            RedisWarmupError: If health check fails or pings timeout.
        """
        logger.info("Warming up Redis", extra={"timeout": self._timeout, "max_connections": self._max_connections})

        try:
            async with asyncio.timeout(self._timeout):
                await self._perform_warmup()
        except TimeoutError as e:
            logger.warning("Redis warmup timed out", extra={"timeout": self._timeout})
            raise RedisWarmupError(f"Redis warmup timed out after {self._timeout}s") from e

        logger.info("Redis warmup successful")

    async def _perform_warmup(self) -> None:
        """Internal implementation of Redis warmup."""
        # 1. Initial health check
        is_healthy = await self._check_health()
        if not is_healthy:
            raise RedisWarmupError("Redis health check failed during warmup")

        # 2. Pool warmup (concurrent pings)
        results = await asyncio.gather(
            *(self._redis_client.ping() for _ in range(self._max_connections)),
            return_exceptions=True,
        )

        def is_ping_failed(r: object) -> bool:
            if isinstance(r, Exception):
                return True
            if isinstance(r, dict):
                # For RedisCluster, ping() returns a dict of results from nodes
                return not r or not all(bool(v) for v in r.values())
            return not bool(r)

        failed_count = sum(1 for r in results if is_ping_failed(r))
        if failed_count > 0:
            logger.warning(
                "Some Redis warmup pings failed",
                extra={"total": self._max_connections, "count": failed_count},
            )
            # We treat any failure as a reason to raise since we want "perfect code"
            raise RedisWarmupError(f"{failed_count}/{self._max_connections} Redis warmup pings failed")

    async def _check_health(self) -> bool:
        """Check if Redis is healthy using available tools."""
        if REDIS_INFRA_AVAILABLE and check_async_redis_health is not None:
            # RedisClientProtocol is structurally compatible with Redis/RedisCluster
            # from redis-client-kit
            result = await check_async_redis_health(self._redis_client)
            return bool(result)

        try:
            # Fallback to simple ping if redis-client-kit is not available
            ping_result = await self._redis_client.ping()
            return bool(ping_result)
        except (asyncio.CancelledError, KeyboardInterrupt):
            raise
        except RedisError as e:
            logger.warning("Redis ping failed during health check", extra={"error": str(e)})
            return False

priority property

Get warmer priority.

raise_on_failure property

Whether orchestrator should raise when this warmer fails.

__init__(redis_client, max_connections=None, timeout=DEFAULT_WARMUP_TIMEOUT, priority=0, raise_on_failure=True)

Initialize Redis warmer.

Parameters:

Name Type Description Default
redis_client RedisClientProtocol

Async Redis client instance.

required
max_connections int | None

Number of concurrent pings to fill the pool. Defaults to DEFAULT_WARMUP_POOL_SIZE if None.

None
timeout float

Maximum time to wait for the warmup operation in seconds.

DEFAULT_WARMUP_TIMEOUT
priority int

Execution priority (lower value means higher priority).

0
raise_on_failure bool

Whether warmup failure should fail startup.

True

Raises:

Type Description
ValueError

If max_connections (if provided) or timeout is not positive.

Source code in servicewright/adapters/warmers/redis.py
def __init__(
    self,
    redis_client: RedisClientProtocol,
    max_connections: int | None = None,
    timeout: float = DEFAULT_WARMUP_TIMEOUT,
    priority: int = 0,
    raise_on_failure: bool = True,
) -> None:
    """Initialize Redis warmer.

    Args:
        redis_client: Async Redis client instance.
        max_connections: Number of concurrent pings to fill the pool.
            Defaults to DEFAULT_WARMUP_POOL_SIZE if None.
        timeout: Maximum time to wait for the warmup operation in seconds.
        priority: Execution priority (lower value means higher priority).
        raise_on_failure: Whether warmup failure should fail startup.

    Raises:
        ValueError: If max_connections (if provided) or timeout is not positive.
    """
    if max_connections is not None and max_connections <= 0:
        raise ValueError(f"max_connections must be positive, got {max_connections}")
    if timeout <= 0:
        raise ValueError(f"timeout must be positive, got {timeout}")

    super().__init__(raise_on_failure=raise_on_failure)
    self._redis_client = redis_client
    self._max_connections = max_connections or DEFAULT_WARMUP_POOL_SIZE
    self._timeout = timeout
    self._priority = priority

warmup() async

Perform Redis warmup.

Raises:

Type Description
RedisWarmupError

If health check fails or pings timeout.

Source code in servicewright/adapters/warmers/redis.py
async def warmup(self) -> None:
    """Perform Redis warmup.

    Raises:
        RedisWarmupError: If health check fails or pings timeout.
    """
    logger.info("Warming up Redis", extra={"timeout": self._timeout, "max_connections": self._max_connections})

    try:
        async with asyncio.timeout(self._timeout):
            await self._perform_warmup()
    except TimeoutError as e:
        logger.warning("Redis warmup timed out", extra={"timeout": self._timeout})
        raise RedisWarmupError(f"Redis warmup timed out after {self._timeout}s") from e

    logger.info("Redis warmup successful")

Bases: AsyncWarmer

Warmer for Kafka producer.

Ensures the producer is ready to send messages by fetching metadata via the internal client.

Source code in servicewright/adapters/warmers/kafka.py
class KafkaProducerWarmer(AsyncWarmer):
    """Warmer for Kafka producer.

    Ensures the producer is ready to send messages by fetching metadata
    via the internal client.
    """

    def __init__(
        self,
        producer: Any,
        timeout: float = DEFAULT_WARMUP_TIMEOUT,
        priority: int = 0,
        raise_on_failure: bool = True,
    ) -> None:
        """Initialize Kafka producer warmer.

        Args:
            producer: Async Kafka producer instance.
            timeout: Maximum time to wait for the warmup operation in seconds.
            priority: Execution priority (lower value means higher priority).
            raise_on_failure: Whether warmup failure should fail startup.

        Raises:
            ValueError: If timeout is not positive.
        """
        if timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout}")

        super().__init__(raise_on_failure=raise_on_failure)
        self._producer = producer
        self._timeout = timeout
        self._priority = priority

    @property
    def priority(self) -> int:
        """Get warmer priority."""
        return self._priority

    async def warmup(self) -> None:
        """Perform Kafka warmup.

        Raises:
            KafkaProducerWarmupError: If metadata fetch fails or times out.
        """
        logger.info("Warming up Kafka", extra={"timeout": self._timeout})

        try:
            async with asyncio.timeout(self._timeout):
                await self._perform_warmup()
        except TimeoutError as e:
            logger.warning("Kafka warmup timed out", extra={"timeout": self._timeout})
            raise KafkaProducerWarmupError(f"Kafka warmup timed out after {self._timeout}s") from e
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except KafkaError as e:
            logger.warning("Kafka warmup failed", extra={"error": str(e)})
            raise KafkaProducerWarmupError(f"Kafka warmup failed: {e}") from e
        except Exception as e:
            logger.exception("Unexpected error during Kafka warmup")
            raise KafkaProducerWarmupError(f"Kafka warmup failed due to unexpected error: {e}") from e

        logger.info("Kafka warmup successful")

    async def _perform_warmup(self) -> None:
        """Internal implementation of Kafka warmup."""
        await self._producer.client.fetch_all_metadata()

priority property

Get warmer priority.

raise_on_failure property

Whether orchestrator should raise when this warmer fails.

__init__(producer, timeout=DEFAULT_WARMUP_TIMEOUT, priority=0, raise_on_failure=True)

Initialize Kafka producer warmer.

Parameters:

Name Type Description Default
producer Any

Async Kafka producer instance.

required
timeout float

Maximum time to wait for the warmup operation in seconds.

DEFAULT_WARMUP_TIMEOUT
priority int

Execution priority (lower value means higher priority).

0
raise_on_failure bool

Whether warmup failure should fail startup.

True

Raises:

Type Description
ValueError

If timeout is not positive.

Source code in servicewright/adapters/warmers/kafka.py
def __init__(
    self,
    producer: Any,
    timeout: float = DEFAULT_WARMUP_TIMEOUT,
    priority: int = 0,
    raise_on_failure: bool = True,
) -> None:
    """Initialize Kafka producer warmer.

    Args:
        producer: Async Kafka producer instance.
        timeout: Maximum time to wait for the warmup operation in seconds.
        priority: Execution priority (lower value means higher priority).
        raise_on_failure: Whether warmup failure should fail startup.

    Raises:
        ValueError: If timeout is not positive.
    """
    if timeout <= 0:
        raise ValueError(f"timeout must be positive, got {timeout}")

    super().__init__(raise_on_failure=raise_on_failure)
    self._producer = producer
    self._timeout = timeout
    self._priority = priority

warmup() async

Perform Kafka warmup.

Raises:

Type Description
KafkaProducerWarmupError

If metadata fetch fails or times out.

Source code in servicewright/adapters/warmers/kafka.py
async def warmup(self) -> None:
    """Perform Kafka warmup.

    Raises:
        KafkaProducerWarmupError: If metadata fetch fails or times out.
    """
    logger.info("Warming up Kafka", extra={"timeout": self._timeout})

    try:
        async with asyncio.timeout(self._timeout):
            await self._perform_warmup()
    except TimeoutError as e:
        logger.warning("Kafka warmup timed out", extra={"timeout": self._timeout})
        raise KafkaProducerWarmupError(f"Kafka warmup timed out after {self._timeout}s") from e
    except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
        raise
    except KafkaError as e:
        logger.warning("Kafka warmup failed", extra={"error": str(e)})
        raise KafkaProducerWarmupError(f"Kafka warmup failed: {e}") from e
    except Exception as e:
        logger.exception("Unexpected error during Kafka warmup")
        raise KafkaProducerWarmupError(f"Kafka warmup failed due to unexpected error: {e}") from e

    logger.info("Kafka warmup successful")

adapters.health

Health check that runs SELECT 1 against a SQLAlchemy async database.

Satisfies :class:~servicewright.core.contracts.HealthCheckerProtocol, so it can be registered via HealthRegistry.add_check("postgres", check).

Parameters:

Name Type Description Default
session_maker SessionMakerProtocol

Async session maker (e.g. async_sessionmaker).

required
timeout float

Maximum seconds to wait for the probe query.

DEFAULT_DB_CHECK_TIMEOUT

Raises:

Type Description
ValueError

If timeout is not positive.

ImportError

At construction time if the postgres extra is missing.

Source code in servicewright/adapters/health/postgres.py
class PostgresHealthCheck:
    """Health check that runs ``SELECT 1`` against a SQLAlchemy async database.

    Satisfies :class:`~servicewright.core.contracts.HealthCheckerProtocol`, so it
    can be registered via ``HealthRegistry.add_check("postgres", check)``.

    Args:
        session_maker: Async session maker (e.g. ``async_sessionmaker``).
        timeout: Maximum seconds to wait for the probe query.

    Raises:
        ValueError: If ``timeout`` is not positive.
        ImportError: At construction time if the ``postgres`` extra is missing.
    """

    def __init__(self, session_maker: SessionMakerProtocol, timeout: float = DEFAULT_DB_CHECK_TIMEOUT) -> None:
        if timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout}")
        self._session_maker = session_maker
        self._timeout = timeout
        self._text = _load_text()

    async def check(self) -> bool:
        """Return ``True`` when ``SELECT 1`` succeeds within the timeout."""
        try:
            async with asyncio.timeout(self._timeout):
                async with self._session_maker() as session:
                    await session.execute(self._text("SELECT 1"))
                    return True
        except TimeoutError:
            logger.warning("PostgreSQL health check timed out", extra={"timeout": self._timeout})
            return False
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except Exception:
            logger.exception("PostgreSQL health check failed")
            return False

check() async

Return True when SELECT 1 succeeds within the timeout.

Source code in servicewright/adapters/health/postgres.py
async def check(self) -> bool:
    """Return ``True`` when ``SELECT 1`` succeeds within the timeout."""
    try:
        async with asyncio.timeout(self._timeout):
            async with self._session_maker() as session:
                await session.execute(self._text("SELECT 1"))
                return True
    except TimeoutError:
        logger.warning("PostgreSQL health check timed out", extra={"timeout": self._timeout})
        return False
    except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
        raise
    except Exception:
        logger.exception("PostgreSQL health check failed")
        return False

Health check that pings a Redis server within a timeout.

Satisfies :class:~servicewright.core.contracts.HealthCheckerProtocol, so it can be registered via HealthRegistry.add_check("redis", check).

Parameters:

Name Type Description Default
client RedisClientProtocol

Async Redis client exposing an awaitable ping.

required
timeout float

Maximum seconds to wait for the PING response.

DEFAULT_REDIS_CHECK_TIMEOUT

Raises:

Type Description
ValueError

If timeout is not positive.

ImportError

At construction time if the redis extra is missing.

Source code in servicewright/adapters/health/redis.py
class RedisHealthCheck:
    """Health check that pings a Redis server within a timeout.

    Satisfies :class:`~servicewright.core.contracts.HealthCheckerProtocol`, so it
    can be registered via ``HealthRegistry.add_check("redis", check)``.

    Args:
        client: Async Redis client exposing an awaitable ``ping``.
        timeout: Maximum seconds to wait for the ``PING`` response.

    Raises:
        ValueError: If ``timeout`` is not positive.
        ImportError: At construction time if the ``redis`` extra is missing.
    """

    def __init__(self, client: RedisClientProtocol, timeout: float = DEFAULT_REDIS_CHECK_TIMEOUT) -> None:
        if timeout <= 0:
            raise ValueError(f"timeout must be positive, got {timeout}")
        _ensure_redis_installed()
        self._client = client
        self._timeout = timeout

    async def check(self) -> bool:
        """Return ``True`` when ``PING`` returns a truthy result within timeout."""
        try:
            async with asyncio.timeout(self._timeout):
                result = await self._client.ping()
                return bool(result)
        except TimeoutError:
            logger.warning("Redis health check timed out", extra={"timeout": self._timeout})
            return False
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except Exception:
            logger.exception("Redis health check failed")
            return False

check() async

Return True when PING returns a truthy result within timeout.

Source code in servicewright/adapters/health/redis.py
async def check(self) -> bool:
    """Return ``True`` when ``PING`` returns a truthy result within timeout."""
    try:
        async with asyncio.timeout(self._timeout):
            result = await self._client.ping()
            return bool(result)
    except TimeoutError:
        logger.warning("Redis health check timed out", extra={"timeout": self._timeout})
        return False
    except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
        raise
    except Exception:
        logger.exception("Redis health check failed")
        return False