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."
        )

CORSMiddlewareSettings

Bases: BaseModel

CORSMiddlewareConfig as settings; the lists take JSON lists (ALLOW_ORIGINS='["https://app.example.com"]').

Source code in servicewright/adapters/fastapi/settings.py
class CORSMiddlewareSettings(BaseModel):
    """``CORSMiddlewareConfig`` as settings; the lists take JSON lists (``ALLOW_ORIGINS='["https://app.example.com"]'``)."""

    enabled: bool = Field(default=True, description="Install Starlette's CORSMiddleware")
    allow_origins: list[str] = Field(default_factory=list, description="Origins allowed; empty allows none")
    allow_credentials: bool = Field(default=False, description="Allow cookies and Authorization across origins")
    allow_methods: list[str] = Field(default_factory=lambda: list(DEFAULT_CORS_METHODS), description="Methods allowed")
    allow_headers: list[str] = Field(default_factory=lambda: ["*"], description="Request headers allowed")
    expose_headers: list[str] = Field(default_factory=list, description="Response headers exposed to the browser")
    max_age: int = Field(default=600, ge=0, description="Seconds a preflight response may be cached")

    @model_validator(mode="after")
    def _validate(self) -> CORSMiddlewareSettings:
        # The dataclass owns the credentials-with-wildcard rule; raising it here puts the field path on it.
        self.to_config()
        return self

    def to_config(self) -> CORSMiddlewareConfig:
        return CORSMiddlewareConfig(
            enabled=self.enabled,
            allow_origins=list(self.allow_origins),
            allow_credentials=self.allow_credentials,
            allow_methods=list(self.allow_methods),
            allow_headers=list(self.allow_headers),
            expose_headers=list(self.expose_headers),
            max_age=self.max_age,
        )

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

CorrelationIdMiddlewareSettings

Bases: BaseModel

CorrelationIdMiddlewareConfig as settings: how the request id is echoed.

Source code in servicewright/adapters/fastapi/settings.py
class CorrelationIdMiddlewareSettings(BaseModel):
    """``CorrelationIdMiddlewareConfig`` as settings: how the request id is echoed."""

    enabled: bool = Field(default=True, description="Echo the request id in a response header")
    header_name: str = Field(default=DEFAULT_CORRELATION_ID_HEADER, description="The header it is echoed in")

    def to_config(self) -> CorrelationIdMiddlewareConfig:
        return CorrelationIdMiddlewareConfig(enabled=self.enabled, header_name=self.header_name)

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

GZipMiddlewareSettings

Bases: BaseModel

GZipMiddlewareConfig as settings.

Source code in servicewright/adapters/fastapi/settings.py
class GZipMiddlewareSettings(BaseModel):
    """``GZipMiddlewareConfig`` as settings."""

    enabled: bool = Field(default=True, description="Compress responses")
    minimum_size: int = Field(default=1000, ge=0, description="Smallest response body compressed, bytes")

    def to_config(self) -> GZipMiddlewareConfig:
        return GZipMiddlewareConfig(enabled=self.enabled, minimum_size=self.minimum_size)

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

HealthSettings

Bases: BaseModel

HealthConfig as settings: the /system probe routes.

Source code in servicewright/adapters/fastapi/settings.py
class HealthSettings(BaseModel):
    """``HealthConfig`` as settings: the ``/system`` probe routes."""

    enabled: bool = Field(default=True, description="Serve the liveness and readiness routes")
    liveness_path: str = Field(default=DEFAULT_LIVENESS_PATH, description="Liveness probe path")
    readiness_path: str = Field(default=DEFAULT_READINESS_PATH, description="Readiness probe path")

    def to_config(self) -> HealthConfig:
        return HealthConfig(enabled=self.enabled, liveness_path=self.liveness_path, readiness_path=self.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.

HttpServerSettings

Bases: BaseModel

HttpConfig as settings, plus the middlewares section the entrypoint takes beside it.

port=0 is rejected unless allow_ephemeral_port says so: 0 only ever arrives here from configuration, and a pod on an ephemeral port is one no probe or load balancer reaches. version is the argument of :meth:to_config, not a field: it is the application's version, which the settings object carries once already.

Source code in servicewright/adapters/fastapi/settings.py
class HttpServerSettings(BaseModel):
    """``HttpConfig`` as settings, plus the ``middlewares`` section the entrypoint takes beside it.

    ``port=0`` is rejected unless ``allow_ephemeral_port`` says so: ``0`` only ever
    arrives here from configuration, and a pod on an ephemeral port is one no
    probe or load balancer reaches. ``version`` is the argument of
    :meth:`to_config`, not a field: it is the application's version, which the
    settings object carries once already.
    """

    host: str = Field(default=DEFAULT_HTTP_HOST, description="Bind host")
    port: int = Field(default=DEFAULT_HTTP_PORT, ge=0, le=65535, description="Bind port")
    allow_ephemeral_port: bool = Field(
        default=False,
        description="Accept port=0 (ephemeral); off by default, a probe targeting a fixed port cannot reach one",
    )
    graceful_timeout: float = Field(
        default=DEFAULT_GRACEFUL_TIMEOUT_SECONDS,
        description="Seconds uvicorn lets in-flight requests finish on shutdown; 0 waits indefinitely",
    )
    title: str | None = Field(default=None, description="OpenAPI title; None falls back to AppSpec.service_name")
    openapi_url: str = Field(default=DEFAULT_OPENAPI_URL, description="Schema path")
    docs_url: str = Field(default=DEFAULT_DOCS_URL, description="Swagger UI path")
    redoc_url: str = Field(default=DEFAULT_REDOC_URL, description="ReDoc path")
    redirect_slashes: bool = Field(default=False, description="Redirect between /path and /path/")
    fastapi_kwargs: dict[str, Any] = Field(
        default_factory=dict, description="Merged into the FastAPI(...) call; JSON in the environment"
    )
    uvicorn: UvicornSettings = Field(default_factory=UvicornSettings)
    uvicorn_kwargs: dict[str, Any] = Field(
        default_factory=dict,
        description="Merged into the uvicorn.Config(...) call over the uvicorn section, so it wins on a collision; "
        "JSON in the environment",
    )
    health: HealthSettings = Field(default_factory=HealthSettings)
    middlewares: MiddlewareSettings = Field(default_factory=MiddlewareSettings)

    @model_validator(mode="after")
    def _validate_port(self) -> HttpServerSettings:
        if self.port == 0 and not self.allow_ephemeral_port:
            raise ValueError(
                "server port=0 binds an ephemeral port and a probe or load balancer targeting a fixed port will not "
                "reach it; set allow_ephemeral_port=True if that is intended"
            )
        return self

    def to_config(self, *, version: str = "0.0.0") -> HttpConfig:
        """The ``HttpConfig`` these settings describe; ``version`` is the OpenAPI version, your ``app_version``.

        ``middlewares`` is not part of it: the entrypoint takes that beside the
        config, as ``middlewares=settings.server.middlewares.to_config()``.
        """
        return HttpConfig(
            host=self.host,
            port=self.port,
            graceful_timeout=self.graceful_timeout,
            title=self.title,
            version=version,
            openapi_url=self.openapi_url,
            docs_url=self.docs_url,
            redoc_url=self.redoc_url,
            redirect_slashes=self.redirect_slashes,
            fastapi_kwargs=dict(self.fastapi_kwargs),
            uvicorn_kwargs={**self.uvicorn.to_kwargs(), **self.uvicorn_kwargs},
            health=self.health.to_config(),
        )

to_config(*, version='0.0.0')

The HttpConfig these settings describe; version is the OpenAPI version, your app_version.

middlewares is not part of it: the entrypoint takes that beside the config, as middlewares=settings.server.middlewares.to_config().

Source code in servicewright/adapters/fastapi/settings.py
def to_config(self, *, version: str = "0.0.0") -> HttpConfig:
    """The ``HttpConfig`` these settings describe; ``version`` is the OpenAPI version, your ``app_version``.

    ``middlewares`` is not part of it: the entrypoint takes that beside the
    config, as ``middlewares=settings.server.middlewares.to_config()``.
    """
    return HttpConfig(
        host=self.host,
        port=self.port,
        graceful_timeout=self.graceful_timeout,
        title=self.title,
        version=version,
        openapi_url=self.openapi_url,
        docs_url=self.docs_url,
        redoc_url=self.redoc_url,
        redirect_slashes=self.redirect_slashes,
        fastapi_kwargs=dict(self.fastapi_kwargs),
        uvicorn_kwargs={**self.uvicorn.to_kwargs(), **self.uvicorn_kwargs},
        health=self.health.to_config(),
    )

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))

LoggingMiddlewareSettings

Bases: BaseModel

LoggingMiddlewareConfig as settings; ignored_paths takes a JSON list.

Source code in servicewright/adapters/fastapi/settings.py
class LoggingMiddlewareSettings(BaseModel):
    """``LoggingMiddlewareConfig`` as settings; ``ignored_paths`` takes a JSON list."""

    enabled: bool = Field(default=True, description="One structured log line per request")
    ignored_paths: list[str] = Field(
        default_factory=lambda: list(DEFAULT_IGNORED_PATHS), description="Paths left out of request logging"
    )

    def to_config(self) -> LoggingMiddlewareConfig:
        return LoggingMiddlewareConfig(enabled=self.enabled, ignored_paths=list(self.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)

    # Passed to ``Instrumentator.add()`` before ``instrument()``: the library's
    # per-request callables (``metrics.request_size()`` and friends, or your
    # own). Any given here REPLACE the default set rather than extend it.
    instrumentations: Sequence[Any] = ()

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.

MiddlewareSettings

Bases: BaseModel

MiddlewareConfig as settings: the toggles and the four configured middlewares.

unit_scope, context_setters and custom are not fields: whether the DI integration owns the request scope and which middleware classes to append is decided by the code that wires them, so they are the keyword arguments of :meth:to_config, with the dataclass defaults.

Source code in servicewright/adapters/fastapi/settings.py
class MiddlewareSettings(BaseModel):
    """``MiddlewareConfig`` as settings: the toggles and the four configured middlewares.

    ``unit_scope``, ``context_setters`` and ``custom`` are not fields: whether the
    DI integration owns the request scope and which middleware classes to append
    is decided by the code that wires them, so they are the keyword arguments of
    :meth:`to_config`, with the dataclass defaults.
    """

    context: bool = Field(default=True, description="ContextMiddleware: correlation ids, bound and echoed")
    sentry: bool = Field(default=True, description="SentryMiddleware: enrich the Sentry scope from the request")
    processing_time: bool = Field(default=True, description="X-Process-Time response header")
    logging: LoggingMiddlewareSettings = Field(default_factory=LoggingMiddlewareSettings)
    correlation_id: CorrelationIdMiddlewareSettings = Field(default_factory=CorrelationIdMiddlewareSettings)
    gzip: GZipMiddlewareSettings = Field(default_factory=GZipMiddlewareSettings)
    cors: CORSMiddlewareSettings = Field(default_factory=CORSMiddlewareSettings)

    def to_config(
        self,
        *,
        unit_scope: bool = True,
        context_setters: list[Any] | None = None,
        custom: Sequence[tuple[type, dict[str, Any]]] = (),
    ) -> MiddlewareConfig:
        """The ``MiddlewareConfig`` these settings describe; the keywords are the members no environment carries."""
        return MiddlewareConfig(
            unit_scope=unit_scope,
            context=self.context,
            sentry=self.sentry,
            processing_time=self.processing_time,
            context_setters=context_setters,
            logging=self.logging.to_config(),
            correlation_id=self.correlation_id.to_config(),
            gzip=self.gzip.to_config(),
            cors=self.cors.to_config(),
            custom=list(custom),
        )

to_config(*, unit_scope=True, context_setters=None, custom=())

The MiddlewareConfig these settings describe; the keywords are the members no environment carries.

Source code in servicewright/adapters/fastapi/settings.py
def to_config(
    self,
    *,
    unit_scope: bool = True,
    context_setters: list[Any] | None = None,
    custom: Sequence[tuple[type, dict[str, Any]]] = (),
) -> MiddlewareConfig:
    """The ``MiddlewareConfig`` these settings describe; the keywords are the members no environment carries."""
    return MiddlewareConfig(
        unit_scope=unit_scope,
        context=self.context,
        sentry=self.sentry,
        processing_time=self.processing_time,
        context_setters=context_setters,
        logging=self.logging.to_config(),
        correlation_id=self.correlation_id.to_config(),
        gzip=self.gzip.to_config(),
        cors=self.cors.to_config(),
        custom=list(custom),
    )

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)

UvicornSettings

Bases: BaseModel

HttpConfig.uvicorn_kwargs as typed fields: the operational uvicorn knobs.

Every field is None until set and only a set field reaches uvicorn.Config, so uvicorn's own defaults apply to the rest and nothing here pins them. The set is the knobs that do something under the runner, which pre-binds the socket and drives one uvicorn.Server directly: workers is read only by uvicorn.run()'s multiprocess supervisor, reload, uds/fd, loop and log_config are ignored or owned by the runner, and host, port and timeout_graceful_shutdown are :class:HttpServerSettings's own. Anything else goes through HttpServerSettings.uvicorn_kwargs.

Source code in servicewright/adapters/fastapi/settings.py
class UvicornSettings(BaseModel):
    """``HttpConfig.uvicorn_kwargs`` as typed fields: the operational uvicorn knobs.

    Every field is ``None`` until set and only a set field reaches
    ``uvicorn.Config``, so uvicorn's own defaults apply to the rest and nothing
    here pins them. The set is the knobs that do something under the runner,
    which pre-binds the socket and drives one ``uvicorn.Server`` directly:
    ``workers`` is read only by ``uvicorn.run()``'s multiprocess supervisor,
    ``reload``, ``uds``/``fd``, ``loop`` and ``log_config`` are ignored or owned
    by the runner, and ``host``, ``port`` and ``timeout_graceful_shutdown`` are
    :class:`HttpServerSettings`'s own. Anything else goes through
    ``HttpServerSettings.uvicorn_kwargs``.
    """

    proxy_headers: bool | None = Field(default=None, description="Trust X-Forwarded-* from forwarded_allow_ips")
    forwarded_allow_ips: list[str] | str | None = Field(
        default=None,
        description="Proxies whose X-Forwarded-* headers are trusted: a list, a comma-separated string or *",
    )
    root_path: str | None = Field(default=None, description="ASGI root_path, for an app served under a prefix")
    server_header: bool | None = Field(default=None, description="Send the Server response header")
    date_header: bool | None = Field(default=None, description="Send the Date response header")
    timeout_keep_alive: int | None = Field(
        default=None, ge=0, description="Seconds an idle keep-alive connection is held"
    )
    backlog: int | None = Field(default=None, ge=0, description="Listen backlog")
    limit_concurrency: int | None = Field(
        default=None, ge=1, description="Concurrent connections or tasks before new requests get a 503"
    )
    limit_max_requests: int | None = Field(
        default=None, ge=1, description="Requests served before the server exits (the Host then stops the process)"
    )
    h11_max_incomplete_event_size: int | None = Field(
        default=None, ge=1, description="Largest incomplete HTTP event (request line plus headers), bytes"
    )
    access_log: bool | None = Field(
        default=None, description="uvicorn's own access log; the logging middleware writes one line per request already"
    )
    log_level: Literal["critical", "error", "warning", "info", "debug", "trace"] | None = Field(
        default=None, description="Level of uvicorn's own loggers; case-insensitive on input"
    )
    ssl_keyfile: str | None = Field(default=None, description="TLS private key path; with ssl_certfile, serves HTTPS")
    ssl_certfile: str | None = Field(default=None, description="TLS certificate path")
    ssl_keyfile_password: str | None = Field(default=None, description="Password of the private key")
    ssl_ca_certs: str | None = Field(default=None, description="CA bundle path, for client certificates")
    ssl_cert_reqs: int | None = Field(
        default=None,
        ge=0,
        le=2,
        description="Client certificate policy, ssl.VerifyMode: 0 none, 1 optional, 2 required",
    )
    ssl_ciphers: str | None = Field(default=None, description="OpenSSL cipher list")

    @field_validator("log_level", mode="before")
    @classmethod
    def _lowercase_level(cls, value: object) -> object:
        return value.lower() if isinstance(value, str) else value

    def to_kwargs(self) -> dict[str, Any]:
        """The fields that were set, by uvicorn's names, for ``HttpConfig.uvicorn_kwargs``."""
        return self.model_dump(exclude_none=True)

to_kwargs()

The fields that were set, by uvicorn's names, for HttpConfig.uvicorn_kwargs.

Source code in servicewright/adapters/fastapi/settings.py
def to_kwargs(self) -> dict[str, Any]:
    """The fields that were set, by uvicorn's names, for ``HttpConfig.uvicorn_kwargs``."""
    return self.model_dump(exclude_none=True)

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 = Instrumentator(**init_kwargs).add(*cfg.instrumentations)
    instrumentator.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}")

    @classmethod
    def from_settings(
        cls,
        settings: GrpcServerSettingsProtocol,
        *,
        health_service_names: tuple[str, ...] = (),
        health_refresh_interval: float = DEFAULT_HEALTH_REFRESH_INTERVAL_SECONDS,
        reflection_service_names: list[str] | None = None,
    ) -> GrpcConfig:
        """Build the config from a grpc-server-kit settings object.

        ``settings`` is anything satisfying the kit's
        ``GrpcServerSettingsProtocol`` — its ``BaseGrpcServerSettings`` pydantic
        model, its stdlib ``GrpcServerConfig``, a model of your own — and every
        field the protocol declares is read from it, defaults included: the kit
        binds ``[::]`` and drains for 5 s where this class binds ``0.0.0.0`` and
        drains for 30 s, and the kit's values win. The mapping lives here so a
        field added to this class cannot go missing from a service's copy of it.

        The keyword arguments are the three fields no settings object carries.
        ``health_service_names`` and ``reflection_service_names`` name your own
        servicers, which is code, not environment. ``health_refresh_interval``
        is deliberately not derived from the kit's ``health.cache_ttl``: the two
        agree while positive and disagree at zero, where ``cache_ttl=0`` means
        "re-check on every probe" and ``health_refresh_interval=0`` means "never
        re-evaluate". The kit's ``metrics_enabled`` has no field here either;
        that switch is ``GrpcEntrypoint(enable_metrics=...)``.
        """
        return cls(
            host=settings.host,
            port=settings.port,
            grace_period=settings.grace_period,
            enable_reflection=settings.enable_reflection,
            enable_channelz=settings.enable_channelz,
            health_service_names=health_service_names,
            health_refresh_interval=health_refresh_interval,
            reflection_service_names=reflection_service_names,
            max_concurrent_rpcs=settings.max_concurrent_rpcs,
            keepalive_time_ms=settings.keepalive_time_ms,
            keepalive_timeout_ms=settings.keepalive_timeout_ms,
            keepalive_permit_without_calls=settings.keepalive_permit_without_calls,
            http2_min_recv_ping_interval_without_data_ms=settings.http2_min_recv_ping_interval_without_data_ms,
            http2_max_pings_without_data=settings.http2_max_pings_without_data,
            max_send_message_length=settings.max_send_message_length,
            max_receive_message_length=settings.max_receive_message_length,
            max_metadata_size=settings.max_metadata_size,
            initial_stream_window_size=settings.initial_stream_window_size,
            initial_connection_window_size=settings.initial_connection_window_size,
            max_connection_idle_ms=settings.max_connection_idle_ms,
            max_connection_age_ms=settings.max_connection_age_ms,
            max_connection_age_grace_ms=settings.max_connection_age_grace_ms,
            compression_algorithm=settings.compression_algorithm,
            ssl_enabled=settings.ssl_enabled,
            ssl_cert_file=settings.ssl_cert_file,
            ssl_key_file=settings.ssl_key_file,
            ssl_ca_file=settings.ssl_ca_file,
            ssl_client_auth=settings.ssl_client_auth,
            ssl_max_cert_size=settings.ssl_max_cert_size,
        )

    @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}")

from_settings(settings, *, health_service_names=(), health_refresh_interval=DEFAULT_HEALTH_REFRESH_INTERVAL_SECONDS, reflection_service_names=None) classmethod

Build the config from a grpc-server-kit settings object.

settings is anything satisfying the kit's GrpcServerSettingsProtocol — its BaseGrpcServerSettings pydantic model, its stdlib GrpcServerConfig, a model of your own — and every field the protocol declares is read from it, defaults included: the kit binds [::] and drains for 5 s where this class binds 0.0.0.0 and drains for 30 s, and the kit's values win. The mapping lives here so a field added to this class cannot go missing from a service's copy of it.

The keyword arguments are the three fields no settings object carries. health_service_names and reflection_service_names name your own servicers, which is code, not environment. health_refresh_interval is deliberately not derived from the kit's health.cache_ttl: the two agree while positive and disagree at zero, where cache_ttl=0 means "re-check on every probe" and health_refresh_interval=0 means "never re-evaluate". The kit's metrics_enabled has no field here either; that switch is GrpcEntrypoint(enable_metrics=...).

Source code in servicewright/adapters/grpc/config.py
@classmethod
def from_settings(
    cls,
    settings: GrpcServerSettingsProtocol,
    *,
    health_service_names: tuple[str, ...] = (),
    health_refresh_interval: float = DEFAULT_HEALTH_REFRESH_INTERVAL_SECONDS,
    reflection_service_names: list[str] | None = None,
) -> GrpcConfig:
    """Build the config from a grpc-server-kit settings object.

    ``settings`` is anything satisfying the kit's
    ``GrpcServerSettingsProtocol`` — its ``BaseGrpcServerSettings`` pydantic
    model, its stdlib ``GrpcServerConfig``, a model of your own — and every
    field the protocol declares is read from it, defaults included: the kit
    binds ``[::]`` and drains for 5 s where this class binds ``0.0.0.0`` and
    drains for 30 s, and the kit's values win. The mapping lives here so a
    field added to this class cannot go missing from a service's copy of it.

    The keyword arguments are the three fields no settings object carries.
    ``health_service_names`` and ``reflection_service_names`` name your own
    servicers, which is code, not environment. ``health_refresh_interval``
    is deliberately not derived from the kit's ``health.cache_ttl``: the two
    agree while positive and disagree at zero, where ``cache_ttl=0`` means
    "re-check on every probe" and ``health_refresh_interval=0`` means "never
    re-evaluate". The kit's ``metrics_enabled`` has no field here either;
    that switch is ``GrpcEntrypoint(enable_metrics=...)``.
    """
    return cls(
        host=settings.host,
        port=settings.port,
        grace_period=settings.grace_period,
        enable_reflection=settings.enable_reflection,
        enable_channelz=settings.enable_channelz,
        health_service_names=health_service_names,
        health_refresh_interval=health_refresh_interval,
        reflection_service_names=reflection_service_names,
        max_concurrent_rpcs=settings.max_concurrent_rpcs,
        keepalive_time_ms=settings.keepalive_time_ms,
        keepalive_timeout_ms=settings.keepalive_timeout_ms,
        keepalive_permit_without_calls=settings.keepalive_permit_without_calls,
        http2_min_recv_ping_interval_without_data_ms=settings.http2_min_recv_ping_interval_without_data_ms,
        http2_max_pings_without_data=settings.http2_max_pings_without_data,
        max_send_message_length=settings.max_send_message_length,
        max_receive_message_length=settings.max_receive_message_length,
        max_metadata_size=settings.max_metadata_size,
        initial_stream_window_size=settings.initial_stream_window_size,
        initial_connection_window_size=settings.initial_connection_window_size,
        max_connection_idle_ms=settings.max_connection_idle_ms,
        max_connection_age_ms=settings.max_connection_age_ms,
        max_connection_age_grace_ms=settings.max_connection_age_grace_ms,
        compression_algorithm=settings.compression_algorithm,
        ssl_enabled=settings.ssl_enabled,
        ssl_cert_file=settings.ssl_cert_file,
        ssl_key_file=settings.ssl_key_file,
        ssl_ca_file=settings.ssl_ca_file,
        ssl_client_auth=settings.ssl_client_auth,
        ssl_max_cert_size=settings.ssl_max_cert_size,
    )

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, unhandled-error 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, and still gets first refusal on everything else.

()
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. Turning it off does not uninstall :class:~servicewright.adapters.grpc.UnhandledErrorInterceptor: a ServiceError then reaches the client as a masked INTERNAL like any other unhandled exception, never as its own message.

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, unhandled-error 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, and still gets first refusal on everything else.
        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``. Turning it off does not uninstall
            :class:`~servicewright.adapters.grpc.UnhandledErrorInterceptor`:
            a ``ServiceError`` then reaches the client as a masked ``INTERNAL``
            like any other unhandled exception, never as its own message.
        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)]

        # Inside the unit scope (so the masked abort is logged with the RPC's
        # correlation ids) but outside everything else: nothing below may leave
        # without a status, and grpc.aio's own answer for an exception it was
        # not told about is UNKNOWN with repr() of it. Not optional, for the
        # same reason UnhandledErrorMiddleware is not optional over HTTP.
        interceptors.append(UnhandledErrorInterceptor())

        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 as the innermost interceptor, so a domain error becomes a mapped abort before any interceptor of yours sees it. Any other exception passes through untouched, to your own interceptors first and to :class:UnhandledErrorInterceptor last.

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` as the innermost
    interceptor, so a domain error becomes a mapped abort before any
    interceptor of yours sees it. Any other exception passes through untouched,
    to your own interceptors first and to :class:`UnhandledErrorInterceptor`
    last.
    """

    async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
        """Convert a raised ``ServiceError`` into a mapped gRPC abort."""
        try:
            yield
        except ServiceError as exc:
            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},
                )
            await _abort(call, mask_private_error(ErrorInfo.from_service_error(exc)))

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:
        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},
            )
        await _abort(call, mask_private_error(ErrorInfo.from_service_error(exc)))

UnhandledErrorInterceptor

Bases: AsyncServerInterceptor

Abort with a masked INTERNAL when an RPC fails with anything else.

The last resort, and the gRPC counterpart of the HTTP stack's UnhandledErrorMiddleware: grpc.aio answers an exception it was never told about with UNKNOWN and repr of the exception, which puts a message nobody wrote for a client — a DSN, a row of a query — on the wire. Here the client gets exactly what a public=False :class:~servicewright.core.errors.ServiceError produces (INTERNAL, detail internal_error, x-error-code: internal_error) and the real exception goes to the log with its traceback.

:class:GrpcEntrypoint adds it inside :class:UnitScopeInterceptor — so the log record still carries the correlation ids — and outside your own interceptors, so an exception type you map yourself reaches your interceptor first and never gets here. A deliberate status passes through: grpc.aio.AbortError and grpc.RpcError are re-raised, and asyncio.CancelledError is a BaseException, so a caller that walks away is not an error to report.

Source code in servicewright/adapters/grpc/errors.py
class UnhandledErrorInterceptor(AsyncServerInterceptor):
    """Abort with a masked ``INTERNAL`` when an RPC fails with anything else.

    The last resort, and the gRPC counterpart of the HTTP stack's
    ``UnhandledErrorMiddleware``: ``grpc.aio`` answers an exception it was never
    told about with ``UNKNOWN`` and ``repr`` of the exception, which puts a
    message nobody wrote for a client — a DSN, a row of a query — on the wire.
    Here the client gets exactly what a ``public=False``
    :class:`~servicewright.core.errors.ServiceError` produces (``INTERNAL``,
    detail ``internal_error``, ``x-error-code: internal_error``) and the real
    exception goes to the log with its traceback.

    :class:`GrpcEntrypoint` adds it inside :class:`UnitScopeInterceptor` — so
    the log record still carries the correlation ids — and outside your own
    interceptors, so an exception type you map yourself reaches your
    interceptor first and never gets here. A deliberate status passes through:
    ``grpc.aio.AbortError`` and ``grpc.RpcError`` are re-raised, and
    ``asyncio.CancelledError`` is a ``BaseException``, so a caller that walks
    away is not an error to report.
    """

    async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
        """Run the RPC; mask anything that comes out of it without a status."""
        try:
            yield
        except (grpc.aio.AbortError, grpc.RpcError):
            raise
        except Exception:
            logger.exception("Unhandled exception while serving RPC", extra={"grpc_method": call.method_name})
            await _abort(call, mask_private_error(_MASKED_INTERNAL))

around_call(call) async

Run the RPC; mask anything that comes out of it without a status.

Source code in servicewright/adapters/grpc/errors.py
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
    """Run the RPC; mask anything that comes out of it without a status."""
    try:
        yield
    except (grpc.aio.AbortError, grpc.RpcError):
        raise
    except Exception:
        logger.exception("Unhandled exception while serving RPC", extra={"grpc_method": call.method_name})
        await _abort(call, mask_private_error(_MASKED_INTERNAL))

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
enable_metrics bool

Record every run (count by outcome, duration, in-progress, last success) through the app's configured metrics sink (ObsConfig(metrics=...) + the matching extra, e.g. servicewright[metrics] for prometheus) via :class:SchedulerJobMetricsRecorder.

False
metrics_prefix str | None

Optional metric name prefix.

None
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`.
        enable_metrics: Record every run (count by outcome, duration, in-progress,
            last success) through the app's configured metrics sink
            (``ObsConfig(metrics=...)`` + the matching extra, e.g.
            servicewright[metrics] for prometheus) via
            :class:`SchedulerJobMetricsRecorder`.
        metrics_prefix: Optional metric name prefix.
        kind: Telemetry label (default ``"scheduler"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        jobs: Sequence[ScheduledJob],
        enable_metrics: bool = False,
        metrics_prefix: str | None = None,
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        super().__init__()
        self._jobs: list[ScheduledJob] = list(jobs)
        self._enable_metrics = enable_metrics
        self._metrics_prefix = metrics_prefix
        self._metrics: SchedulerJobMetricsRecorder | None = None
        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)
        if self._enable_metrics:
            self._metrics = SchedulerJobMetricsRecorder(ctx.observability.metrics, prefix=self._metrics_prefix)

        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:
            # The tracker sits INSIDE the try so it sees every exception on its
            # way out (and re-raises it): in-progress is released and the run
            # recorded — ok / error / cancelled — before the log lines below.
            with self._track(job_id):
                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)},
        )

    def _track(self, job_id: str) -> contextlib.AbstractContextManager[None]:
        """The metrics tracker for one run (a no-op when metrics are off)."""
        if self._metrics is None:
            return contextlib.nullcontext()
        return self._metrics.track(job_id)

    @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)
    if self._enable_metrics:
        self._metrics = SchedulerJobMetricsRecorder(ctx.observability.metrics, prefix=self._metrics_prefix)

    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."""

SchedulerJobMetricsRecorder

The scheduled-job run recorder over generic instruments.

Parameters:

Name Type Description Default
sink MetricsSinkProtocol

The app's metrics sink (ctx.observability.metrics).

required
prefix str | None

Optional metric name prefix (myapp → myapp_scheduler_job_runs_total).

None
buckets tuple[float, ...]

Duration histogram buckets, in seconds.

DEFAULT_SCHEDULER_BUCKETS
Source code in servicewright/adapters/apscheduler4/metrics.py
class SchedulerJobMetricsRecorder:
    """The scheduled-job run recorder over generic instruments.

    Args:
        sink: The app's metrics sink (``ctx.observability.metrics``).
        prefix: Optional metric name prefix (``myapp`` → ``myapp_scheduler_job_runs_total``).
        buckets: Duration histogram buckets, in seconds.
    """

    def __init__(
        self,
        sink: MetricsSinkProtocol,
        *,
        prefix: str | None = None,
        buckets: tuple[float, ...] = DEFAULT_SCHEDULER_BUCKETS,
    ) -> None:
        self._runs_total = sink.counter(
            make_metric_name(SCHEDULER_JOB_RUNS_TOTAL, prefix),
            "Total number of scheduled job runs",
            SCHEDULER_JOB_RUNS_TOTAL_LABELS,
        )
        self._duration = sink.histogram(
            make_metric_name(SCHEDULER_JOB_DURATION_SECONDS, prefix),
            "Scheduled job run duration in seconds",
            SCHEDULER_JOB_DURATION_LABELS,
            buckets=buckets,
        )
        self._last_success = sink.gauge(
            make_metric_name(SCHEDULER_JOB_LAST_SUCCESS_TIMESTAMP_SECONDS, prefix),
            "Unix timestamp of the last successful run of the job",
            SCHEDULER_JOB_GAUGE_LABELS,
        )
        self._in_progress = sink.gauge(
            make_metric_name(SCHEDULER_JOB_IN_PROGRESS, prefix),
            "Number of runs of the job currently in progress",
            SCHEDULER_JOB_GAUGE_LABELS,
        )

    def mark_started(self, job_id: str) -> None:
        """Count one more run of ``job_id`` as in progress."""
        self._in_progress.inc(job_id=job_id)

    def mark_finished(self, job_id: str) -> None:
        """Count one run of ``job_id`` as no longer in progress (every exit path)."""
        self._in_progress.dec(job_id=job_id)

    def record_run(self, job_id: str, *, duration: float, error: BaseException | None = None) -> None:
        """Record one finished run: its outcome, its duration and — on success only — the time.

        A cancelled run has no duration worth a histogram sample (it did not
        finish), so only ``ok`` and ``error`` runs are observed.
        """
        outcome, error_kind = classify_outcome(error)
        self._runs_total.inc(job_id=job_id, outcome=outcome, error_kind=error_kind)
        if outcome != OUTCOME_CANCELLED:
            self._duration.observe(duration, job_id=job_id)
        if outcome == OUTCOME_OK:
            self._last_success.set(time.time(), job_id=job_id)

    @contextlib.contextmanager
    def track(self, job_id: str) -> Iterator[None]:
        """Account one run of ``job_id`` around its body.

        In-progress goes up on entry; on exit — normal, error or cancellation —
        it goes down and the run is recorded from the exception, which is then
        re-raised untouched.
        """
        self.mark_started(job_id)
        start = time.perf_counter()
        error: BaseException | None = None
        try:
            yield
        except BaseException as exc:
            error = exc
            raise
        finally:
            self.mark_finished(job_id)
            self.record_run(job_id, duration=time.perf_counter() - start, error=error)

mark_finished(job_id)

Count one run of job_id as no longer in progress (every exit path).

Source code in servicewright/adapters/apscheduler4/metrics.py
def mark_finished(self, job_id: str) -> None:
    """Count one run of ``job_id`` as no longer in progress (every exit path)."""
    self._in_progress.dec(job_id=job_id)

mark_started(job_id)

Count one more run of job_id as in progress.

Source code in servicewright/adapters/apscheduler4/metrics.py
def mark_started(self, job_id: str) -> None:
    """Count one more run of ``job_id`` as in progress."""
    self._in_progress.inc(job_id=job_id)

record_run(job_id, *, duration, error=None)

Record one finished run: its outcome, its duration and — on success only — the time.

A cancelled run has no duration worth a histogram sample (it did not finish), so only ok and error runs are observed.

Source code in servicewright/adapters/apscheduler4/metrics.py
def record_run(self, job_id: str, *, duration: float, error: BaseException | None = None) -> None:
    """Record one finished run: its outcome, its duration and — on success only — the time.

    A cancelled run has no duration worth a histogram sample (it did not
    finish), so only ``ok`` and ``error`` runs are observed.
    """
    outcome, error_kind = classify_outcome(error)
    self._runs_total.inc(job_id=job_id, outcome=outcome, error_kind=error_kind)
    if outcome != OUTCOME_CANCELLED:
        self._duration.observe(duration, job_id=job_id)
    if outcome == OUTCOME_OK:
        self._last_success.set(time.time(), job_id=job_id)

track(job_id)

Account one run of job_id around its body.

In-progress goes up on entry; on exit — normal, error or cancellation — it goes down and the run is recorded from the exception, which is then re-raised untouched.

Source code in servicewright/adapters/apscheduler4/metrics.py
@contextlib.contextmanager
def track(self, job_id: str) -> Iterator[None]:
    """Account one run of ``job_id`` around its body.

    In-progress goes up on entry; on exit — normal, error or cancellation —
    it goes down and the run is recorded from the exception, which is then
    re-raised untouched.
    """
    self.mark_started(job_id)
    start = time.perf_counter()
    error: BaseException | None = None
    try:
        yield
    except BaseException as exc:
        error = exc
        raise
    finally:
        self.mark_finished(job_id)
        self.record_run(job_id, duration=time.perf_counter() - start, error=error)

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],
        enable_metrics: bool = False,
        metrics_prefix: str | None = None,
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        self._entrypoint = SchedulerEntrypoint(
            jobs=jobs,
            enable_metrics=enable_metrics,
            metrics_prefix=metrics_prefix,
            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
enable_metrics bool

Record every run (count by outcome, duration, in-progress, last success) through the app's configured metrics sink (ObsConfig(metrics=...) + the matching extra, e.g. servicewright[metrics] for prometheus) via :class:SchedulerJobMetricsRecorder.

False
metrics_prefix str | None

Optional metric name prefix.

None
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`.
        enable_metrics: Record every run (count by outcome, duration, in-progress,
            last success) through the app's configured metrics sink
            (``ObsConfig(metrics=...)`` + the matching extra, e.g.
            servicewright[metrics] for prometheus) via
            :class:`SchedulerJobMetricsRecorder`.
        metrics_prefix: Optional metric name prefix.
        kind: Telemetry label (default ``"scheduler"``).
        essential: Whether the entrypoint's exit/failure stops the process.
    """

    def __init__(
        self,
        *,
        jobs: Sequence[ScheduledJob],
        enable_metrics: bool = False,
        metrics_prefix: str | None = None,
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        super().__init__()
        self._jobs: list[ScheduledJob] = list(jobs)
        self._enable_metrics = enable_metrics
        self._metrics_prefix = metrics_prefix
        self._metrics: SchedulerJobMetricsRecorder | None = None
        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)
        if self._enable_metrics:
            self._metrics = SchedulerJobMetricsRecorder(ctx.observability.metrics, prefix=self._metrics_prefix)

        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:
            # The tracker sits INSIDE the try so it sees every exception on its
            # way out (and re-raises it): in-progress is released and the run
            # recorded — ok / error / cancelled — before the log lines below.
            with self._track(job_id):
                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)},
        )

    def _track(self, job_id: str) -> contextlib.AbstractContextManager[None]:
        """The metrics tracker for one run (a no-op when metrics are off)."""
        if self._metrics is None:
            return contextlib.nullcontext()
        return self._metrics.track(job_id)

    @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)
    if self._enable_metrics:
        self._metrics = SchedulerJobMetricsRecorder(ctx.observability.metrics, prefix=self._metrics_prefix)

    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."""

SchedulerJobMetricsRecorder

The scheduled-job run recorder over generic instruments.

Parameters:

Name Type Description Default
sink MetricsSinkProtocol

The app's metrics sink (ctx.observability.metrics).

required
prefix str | None

Optional metric name prefix (myapp → myapp_scheduler_job_runs_total).

None
buckets tuple[float, ...]

Duration histogram buckets, in seconds.

DEFAULT_SCHEDULER_BUCKETS
Source code in servicewright/adapters/apscheduler3/metrics.py
class SchedulerJobMetricsRecorder:
    """The scheduled-job run recorder over generic instruments.

    Args:
        sink: The app's metrics sink (``ctx.observability.metrics``).
        prefix: Optional metric name prefix (``myapp`` → ``myapp_scheduler_job_runs_total``).
        buckets: Duration histogram buckets, in seconds.
    """

    def __init__(
        self,
        sink: MetricsSinkProtocol,
        *,
        prefix: str | None = None,
        buckets: tuple[float, ...] = DEFAULT_SCHEDULER_BUCKETS,
    ) -> None:
        self._runs_total = sink.counter(
            make_metric_name(SCHEDULER_JOB_RUNS_TOTAL, prefix),
            "Total number of scheduled job runs",
            SCHEDULER_JOB_RUNS_TOTAL_LABELS,
        )
        self._duration = sink.histogram(
            make_metric_name(SCHEDULER_JOB_DURATION_SECONDS, prefix),
            "Scheduled job run duration in seconds",
            SCHEDULER_JOB_DURATION_LABELS,
            buckets=buckets,
        )
        self._last_success = sink.gauge(
            make_metric_name(SCHEDULER_JOB_LAST_SUCCESS_TIMESTAMP_SECONDS, prefix),
            "Unix timestamp of the last successful run of the job",
            SCHEDULER_JOB_GAUGE_LABELS,
        )
        self._in_progress = sink.gauge(
            make_metric_name(SCHEDULER_JOB_IN_PROGRESS, prefix),
            "Number of runs of the job currently in progress",
            SCHEDULER_JOB_GAUGE_LABELS,
        )

    def mark_started(self, job_id: str) -> None:
        """Count one more run of ``job_id`` as in progress."""
        self._in_progress.inc(job_id=job_id)

    def mark_finished(self, job_id: str) -> None:
        """Count one run of ``job_id`` as no longer in progress (every exit path)."""
        self._in_progress.dec(job_id=job_id)

    def record_run(self, job_id: str, *, duration: float, error: BaseException | None = None) -> None:
        """Record one finished run: its outcome, its duration and — on success only — the time.

        A cancelled run has no duration worth a histogram sample (it did not
        finish), so only ``ok`` and ``error`` runs are observed.
        """
        outcome, error_kind = classify_outcome(error)
        self._runs_total.inc(job_id=job_id, outcome=outcome, error_kind=error_kind)
        if outcome != OUTCOME_CANCELLED:
            self._duration.observe(duration, job_id=job_id)
        if outcome == OUTCOME_OK:
            self._last_success.set(time.time(), job_id=job_id)

    @contextlib.contextmanager
    def track(self, job_id: str) -> Iterator[None]:
        """Account one run of ``job_id`` around its body.

        In-progress goes up on entry; on exit — normal, error or cancellation —
        it goes down and the run is recorded from the exception, which is then
        re-raised untouched.
        """
        self.mark_started(job_id)
        start = time.perf_counter()
        error: BaseException | None = None
        try:
            yield
        except BaseException as exc:
            error = exc
            raise
        finally:
            self.mark_finished(job_id)
            self.record_run(job_id, duration=time.perf_counter() - start, error=error)

mark_finished(job_id)

Count one run of job_id as no longer in progress (every exit path).

Source code in servicewright/adapters/apscheduler3/metrics.py
def mark_finished(self, job_id: str) -> None:
    """Count one run of ``job_id`` as no longer in progress (every exit path)."""
    self._in_progress.dec(job_id=job_id)

mark_started(job_id)

Count one more run of job_id as in progress.

Source code in servicewright/adapters/apscheduler3/metrics.py
def mark_started(self, job_id: str) -> None:
    """Count one more run of ``job_id`` as in progress."""
    self._in_progress.inc(job_id=job_id)

record_run(job_id, *, duration, error=None)

Record one finished run: its outcome, its duration and — on success only — the time.

A cancelled run has no duration worth a histogram sample (it did not finish), so only ok and error runs are observed.

Source code in servicewright/adapters/apscheduler3/metrics.py
def record_run(self, job_id: str, *, duration: float, error: BaseException | None = None) -> None:
    """Record one finished run: its outcome, its duration and — on success only — the time.

    A cancelled run has no duration worth a histogram sample (it did not
    finish), so only ``ok`` and ``error`` runs are observed.
    """
    outcome, error_kind = classify_outcome(error)
    self._runs_total.inc(job_id=job_id, outcome=outcome, error_kind=error_kind)
    if outcome != OUTCOME_CANCELLED:
        self._duration.observe(duration, job_id=job_id)
    if outcome == OUTCOME_OK:
        self._last_success.set(time.time(), job_id=job_id)

track(job_id)

Account one run of job_id around its body.

In-progress goes up on entry; on exit — normal, error or cancellation — it goes down and the run is recorded from the exception, which is then re-raised untouched.

Source code in servicewright/adapters/apscheduler3/metrics.py
@contextlib.contextmanager
def track(self, job_id: str) -> Iterator[None]:
    """Account one run of ``job_id`` around its body.

    In-progress goes up on entry; on exit — normal, error or cancellation —
    it goes down and the run is recorded from the exception, which is then
    re-raised untouched.
    """
    self.mark_started(job_id)
    start = time.perf_counter()
    error: BaseException | None = None
    try:
        yield
    except BaseException as exc:
        error = exc
        raise
    finally:
        self.mark_finished(job_id)
        self.record_run(job_id, duration=time.perf_counter() - start, error=error)

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],
        enable_metrics: bool = False,
        metrics_prefix: str | None = None,
        kind: str = "scheduler",
        essential: bool = True,
    ) -> None:
        self._entrypoint = SchedulerEntrypoint(
            jobs=jobs,
            enable_metrics=enable_metrics,
            metrics_prefix=metrics_prefix,
            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.settings

The settings contract as pydantic-settings models — BaseServiceSettings and the four section models, with the defaults the backends fall back to. See Settings.

The settings contract as pydantic-settings models ([settings] extra).

One model per observability section, carrying the defaults the built-in sinks fall back to, and :class:BaseServiceSettings composing them into an environment-loaded settings class that satisfies :class:~servicewright.core.contracts.BaseServiceSettingsProtocol. Importing this package requires servicewright[settings].

BaseServiceSettings

Bases: BaseSettings

Environment-loaded service settings satisfying BaseServiceSettingsProtocol.

Subclass it: add your own fields, override model_config (pydantic merges it with this one, so env_nested_delimiter="__" survives an env_file or env_prefix of yours), narrow a section to a subclass of its model, or disable a concern with None::

class Settings(BaseServiceSettings):
    model_config = SettingsConfigDict(env_file=".env")

    app_version: str = "1.4.0"
    database_dsn: str

    metrics: None = None  # this concern is off
    logging: LoggingSettings = LoggingSettings(level="DEBUG")

The sections present by default are the inert ones: logging (INFO, JSON), metrics (sink on, exposition server off) and error_tracking (no DSN, so off). tracing is None because a present section installs a tracer provider; TRACING__COLLECTOR_URL=... (any TRACING__* variable) creates it from the environment. A present section activates its backend at bootstrap, which needs that backend's extra installed.

Source code in servicewright/adapters/settings/models.py
class BaseServiceSettings(BaseSettings):
    """Environment-loaded service settings satisfying ``BaseServiceSettingsProtocol``.

    Subclass it: add your own fields, override ``model_config`` (pydantic merges it
    with this one, so ``env_nested_delimiter="__"`` survives an ``env_file`` or
    ``env_prefix`` of yours), narrow a section to a subclass of its model, or
    disable a concern with ``None``::

        class Settings(BaseServiceSettings):
            model_config = SettingsConfigDict(env_file=".env")

            app_version: str = "1.4.0"
            database_dsn: str

            metrics: None = None  # this concern is off
            logging: LoggingSettings = LoggingSettings(level="DEBUG")

    The sections present by default are the inert ones: ``logging`` (INFO, JSON),
    ``metrics`` (sink on, exposition server off) and ``error_tracking`` (no DSN,
    so off). ``tracing`` is ``None`` because a present section installs a tracer
    provider; ``TRACING__COLLECTOR_URL=...`` (any ``TRACING__*`` variable) creates
    it from the environment. A present section activates its backend at
    bootstrap, which needs that backend's extra installed.
    """

    model_config = SettingsConfigDict(env_nested_delimiter="__")

    app_version: str = Field(default="0.0.0", description="Returned by get_app_version()")
    environment: str = Field(default="local", description="Deployment environment, used for observability setup")

    logging: LoggingSettings | None = Field(default_factory=LoggingSettings)
    metrics: MetricsSettings | None = Field(default_factory=MetricsSettings)
    tracing: TracingSettings | None = None
    error_tracking: ErrorTrackingSettings | None = Field(default_factory=ErrorTrackingSettings)

    def get_app_version(self) -> str:
        """Return :attr:`app_version`; override to source the version elsewhere."""
        return self.app_version

get_app_version()

Return :attr:app_version; override to source the version elsewhere.

Source code in servicewright/adapters/settings/models.py
def get_app_version(self) -> str:
    """Return :attr:`app_version`; override to source the version elsewhere."""
    return self.app_version

ErrorTrackingSettings

Bases: BaseModel

settings.error_tracking: reporting endpoint and sampling.

Satisfies :class:~servicewright.core.observability.ErrorTrackingSettingsProtocol. The concern stays off while dsn is empty, so the section can be present by default and switch on from ERROR_TRACKING__DSN alone.

Source code in servicewright/adapters/settings/models.py
class ErrorTrackingSettings(BaseModel):
    """``settings.error_tracking``: reporting endpoint and sampling.

    Satisfies :class:`~servicewright.core.observability.ErrorTrackingSettingsProtocol`.
    The concern stays off while ``dsn`` is empty, so the section can be present by
    default and switch on from ``ERROR_TRACKING__DSN`` alone.
    """

    dsn: str | None = Field(default=None, description="Reporting endpoint; empty keeps the concern off")
    environment: str = Field(default="", description="Environment tag; empty falls back to settings.environment")
    traces_sample_rate: float = Field(default=0.0, ge=0.0, le=1.0, description="Performance-trace sampling")
    profiles_sample_rate: float = Field(default=0.0, ge=0.0, le=1.0, description="Profiling sampling")
    debug: bool = Field(default=False, description="SDK debug mode")

LoggingSettings

Bases: BaseModel

settings.logging: root level and rendering format.

Satisfies :class:~servicewright.core.observability.LoggingSettingsProtocol. The level is validated against :data:~servicewright.core.observability.LogLevelStr (case-insensitively, so LOGGING__LEVEL=debug works) instead of silently falling back to INFO on a typo, which is what the sinks would do.

Source code in servicewright/adapters/settings/models.py
class LoggingSettings(BaseModel):
    """``settings.logging``: root level and rendering format.

    Satisfies :class:`~servicewright.core.observability.LoggingSettingsProtocol`.
    The level is validated against :data:`~servicewright.core.observability.LogLevelStr`
    (case-insensitively, so ``LOGGING__LEVEL=debug`` works) instead of silently
    falling back to ``INFO`` on a typo, which is what the sinks would do.
    """

    level: LogLevelStr = Field(default="INFO", description="Root log level; case-insensitive on input")
    use_json: bool = Field(default=True, description="JSON lines (True) or human-readable console output")

    @field_validator("level", mode="before")
    @classmethod
    def _uppercase_level(cls, value: object) -> object:
        return value.upper() if isinstance(value, str) else value

MetricsSettings

Bases: BaseModel

settings.metrics: the standalone exposition endpoint.

Satisfies :class:~servicewright.core.observability.MetricsSettingsProtocol. A present section activates the metrics sink; enabled only decides whether the sink also serves /metrics on its own port (the FastAPI entrypoint serves /system/metrics regardless). port=0 is rejected unless allow_ephemeral_port says so: the sink has no bound-port readback, so an ephemeral exposition port is one nothing scrapes.

Source code in servicewright/adapters/settings/models.py
class MetricsSettings(BaseModel):
    """``settings.metrics``: the standalone exposition endpoint.

    Satisfies :class:`~servicewright.core.observability.MetricsSettingsProtocol`.
    A present section activates the metrics sink; ``enabled`` only decides whether
    the sink also serves ``/metrics`` on its own port (the FastAPI entrypoint
    serves ``/system/metrics`` regardless). ``port=0`` is rejected unless
    ``allow_ephemeral_port`` says so: the sink has no bound-port readback, so an
    ephemeral exposition port is one nothing scrapes.
    """

    enabled: bool = Field(default=False, description="Start a standalone exposition server")
    host: str = Field(default="0.0.0.0", description="Bind host of that server")
    port: int = Field(default=9090, ge=0, le=65535, description="Bind port of that server")
    allow_ephemeral_port: bool = Field(
        default=False,
        description="Accept port=0 (ephemeral); off by default, a scraper targeting a fixed port cannot reach one",
    )
    prefix: str | None = Field(default=None, description="Metric name prefix, for backends that apply one globally")

    @model_validator(mode="after")
    def _validate_port(self) -> MetricsSettings:
        if self.port == 0 and not self.allow_ephemeral_port:
            raise ValueError(
                "metrics port=0 binds an ephemeral port and a scraper targeting a fixed port will not reach it; "
                "set allow_ephemeral_port=True if that is intended"
            )
        return self

TracingSettings

Bases: BaseModel

settings.tracing: exporter endpoint and sampling.

Satisfies :class:~servicewright.core.observability.TracingSettingsProtocol. A present section installs the tracer provider even without a collector_url (spans are sampled but exported nowhere), which is why :class:BaseServiceSettings leaves it None until configured.

Source code in servicewright/adapters/settings/models.py
class TracingSettings(BaseModel):
    """``settings.tracing``: exporter endpoint and sampling.

    Satisfies :class:`~servicewright.core.observability.TracingSettingsProtocol`.
    A present section installs the tracer provider even without a
    ``collector_url`` (spans are sampled but exported nowhere), which is why
    :class:`BaseServiceSettings` leaves it ``None`` until configured.
    """

    service_name: str = Field(default="", description="Resource service name; empty falls back to AppSpec.service_name")
    collector_url: str | None = Field(default=None, description="OTLP gRPC endpoint; None installs no exporter")
    sample_ratio: float = Field(default=1.0, ge=0.0, le=1.0, description="Ratio for the parent-based sampler")
    insecure: bool = Field(default=True, description="Plaintext OTLP connection")
    enable_console_exporter: bool = Field(default=False, description="Also print spans to stdout")
    excluded_urls: str | None = Field(default=None, description="Comma-separated request paths to skip (FastAPI)")

adapters.observability

The author-facing sink ABCs and the five built-in backends — PrometheusMetricsSink, OtelTracingSink, SentryErrorTrackingSink, StructlogLoggingSink, StdlibLoggingSink — importable from here (each needs its extra). Selected by name, they 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. The concrete backends live in _metrics / _tracing / _errors / _logging, each behind its own extra and import guard, and are reachable from here too — lazily, through a module-level __getattr__ (PEP 562), so the package stays importable with no extra installed and asking for a backend whose extra is missing raises the same ImportError that names the extra::

from servicewright.adapters.observability import SentryErrorTrackingSink

Selected by name (ObsConfig(error_tracking="sentry")) they are resolved 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)."""
        ...

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

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."""
    ...

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

Mint (or reuse) a gauge instrument.

Source code in servicewright/adapters/observability/base.py
@abstractmethod
def gauge(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> GaugeProtocol:
    """Mint (or reuse) a gauge 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)."""
    ...

OtelTracingSink

Bases: TracingSink

OpenTelemetry backend driven by settings.tracing.

Source code in servicewright/adapters/observability/_tracing/otel.py
class OtelTracingSink(TracingSink):
    """OpenTelemetry backend driven by ``settings.tracing``."""

    backend = "otel"

    def __init__(self) -> None:
        self._provider: TracerProvider | None = None

    def setup(self, ctx: ObsSetupContext) -> None:
        """Install the global tracer provider (idempotent per sink instance)."""
        if self._provider is not None:
            return
        otel_settings = getattr(ctx.settings, "tracing", None)
        if otel_settings is None:
            return

        resource = Resource.create(
            {
                "service.name": getattr(otel_settings, "service_name", "") or ctx.service_name,
                "service.version": ctx.app_version,
            }
        )
        sampler = ParentBased(TraceIdRatioBased(getattr(otel_settings, "sample_ratio", 1.0)))
        provider = TracerProvider(resource=resource, sampler=sampler)

        # Must be registered before any exporting processor: on_end runs in
        # registration order, and exporters must only ever see redacted spans.
        if ctx.redactor is not None:
            provider.add_span_processor(_RedactingSpanProcessor(ctx.redactor))

        collector_url = getattr(otel_settings, "collector_url", None)
        if collector_url:
            exporter = OTLPSpanExporter(endpoint=collector_url, insecure=getattr(otel_settings, "insecure", True))
            provider.add_span_processor(BatchSpanProcessor(exporter))
        if getattr(otel_settings, "enable_console_exporter", False):
            provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))

        trace.set_tracer_provider(provider)
        self._provider = provider
        logger.info("OpenTelemetry tracing configured", extra={"collector_url": collector_url})

    def shutdown(self) -> None:
        """Flush and shut the provider down (best-effort)."""
        if self._provider is None:
            return
        try:
            self._provider.shutdown()
        finally:
            self._provider = None

    def tracer(self, name: str) -> TracerProtocol:
        """Mint a tracer from the global provider."""
        return cast("TracerProtocol", trace.get_tracer(name))

    def instrument_fastapi(self, app: Any, *, excluded_urls: str | None = None) -> None:
        """Instrument a live FastAPI app; degrades with a warning when the instrumentor is absent."""
        try:
            from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
        except ImportError:
            logger.warning(
                "OpenTelemetry FastAPI instrumentor not installed; skipping FastAPI instrumentation. "
                "Install opentelemetry-instrumentation-fastapi to enable request tracing."
            )
            return
        FastAPIInstrumentor.instrument_app(app, excluded_urls=excluded_urls)

instrument_fastapi(app, *, excluded_urls=None)

Instrument a live FastAPI app; degrades with a warning when the instrumentor is absent.

Source code in servicewright/adapters/observability/_tracing/otel.py
def instrument_fastapi(self, app: Any, *, excluded_urls: str | None = None) -> None:
    """Instrument a live FastAPI app; degrades with a warning when the instrumentor is absent."""
    try:
        from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
    except ImportError:
        logger.warning(
            "OpenTelemetry FastAPI instrumentor not installed; skipping FastAPI instrumentation. "
            "Install opentelemetry-instrumentation-fastapi to enable request tracing."
        )
        return
    FastAPIInstrumentor.instrument_app(app, excluded_urls=excluded_urls)

setup(ctx)

Install the global tracer provider (idempotent per sink instance).

Source code in servicewright/adapters/observability/_tracing/otel.py
def setup(self, ctx: ObsSetupContext) -> None:
    """Install the global tracer provider (idempotent per sink instance)."""
    if self._provider is not None:
        return
    otel_settings = getattr(ctx.settings, "tracing", None)
    if otel_settings is None:
        return

    resource = Resource.create(
        {
            "service.name": getattr(otel_settings, "service_name", "") or ctx.service_name,
            "service.version": ctx.app_version,
        }
    )
    sampler = ParentBased(TraceIdRatioBased(getattr(otel_settings, "sample_ratio", 1.0)))
    provider = TracerProvider(resource=resource, sampler=sampler)

    # Must be registered before any exporting processor: on_end runs in
    # registration order, and exporters must only ever see redacted spans.
    if ctx.redactor is not None:
        provider.add_span_processor(_RedactingSpanProcessor(ctx.redactor))

    collector_url = getattr(otel_settings, "collector_url", None)
    if collector_url:
        exporter = OTLPSpanExporter(endpoint=collector_url, insecure=getattr(otel_settings, "insecure", True))
        provider.add_span_processor(BatchSpanProcessor(exporter))
    if getattr(otel_settings, "enable_console_exporter", False):
        provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))

    trace.set_tracer_provider(provider)
    self._provider = provider
    logger.info("OpenTelemetry tracing configured", extra={"collector_url": collector_url})

shutdown()

Flush and shut the provider down (best-effort).

Source code in servicewright/adapters/observability/_tracing/otel.py
def shutdown(self) -> None:
    """Flush and shut the provider down (best-effort)."""
    if self._provider is None:
        return
    try:
        self._provider.shutdown()
    finally:
        self._provider = None

tracer(name)

Mint a tracer from the global provider.

Source code in servicewright/adapters/observability/_tracing/otel.py
def tracer(self, name: str) -> TracerProtocol:
    """Mint a tracer from the global provider."""
    return cast("TracerProtocol", trace.get_tracer(name))

PrometheusMetricsSink

Bases: MetricsSink

Prometheus backend: an instrument factory over the (given or default) registry.

Instruments are cached per registry — prometheus collectors register into a registry by name, so a second construction with the same name raises a duplicate error; repeated requests (two gRPC entrypoints sharing a recorder shape, or a second service in the same process) return the same instrument.

Parameters:

Name Type Description Default
registry CollectorRegistry | None

Registry for instruments and the exposition app. None (the default, used by the registry-resolver) means the process-global default REGISTRY — out-of-process emitters (DB pools, clients) write there, so one scrape sees the whole service.

None
Source code in servicewright/adapters/observability/_metrics/prometheus.py
class PrometheusMetricsSink(MetricsSink):
    """Prometheus backend: an instrument factory over the (given or default) registry.

    Instruments are cached per registry — prometheus collectors register into a
    registry by name, so a second construction with the same name raises a
    duplicate error; repeated requests (two gRPC entrypoints sharing a recorder
    shape, or a second service in the same process) return the same instrument.

    Args:
        registry: Registry for instruments and the exposition app. ``None`` (the
            default, used by the registry-resolver) means the process-global
            default ``REGISTRY`` — out-of-process emitters (DB pools, clients)
            write there, so one scrape sees the whole service.
    """

    backend = "prometheus"

    def __init__(self, registry: CollectorRegistry | None = None) -> None:
        self._registry = registry
        self._server: HTTPServer | None = None
        self._thread: threading.Thread | None = None
        self._lock = threading.Lock()

    @property
    def _instruments(self) -> dict[str, Any]:
        """The instruments already registered in this sink's registry."""
        return _instrument_cache(self._registry)

    def setup(self, ctx: ObsSetupContext) -> None:
        """Start the standalone exposition server when settings enable it."""
        metrics_settings = getattr(ctx.settings, "metrics", None)
        if metrics_settings is None or not getattr(metrics_settings, "enabled", False):
            return
        with self._lock:
            if self._server is not None:
                return
            app = make_wsgi_app(self._registry) if self._registry is not None else make_wsgi_app()
            # make_server (not start_http_server) keeps manual control over the
            # server lifecycle so shutdown() can stop it deterministically.
            self._server = make_server(
                host=metrics_settings.host,
                port=metrics_settings.port,
                app=app,
                handler_class=_SilentHandler,
            )
            self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
            self._thread.start()
        logger.info(
            "Prometheus metrics server started",
            extra={"host": metrics_settings.host, "port": metrics_settings.port},
        )

    def shutdown(self) -> None:
        """Stop the exposition server (no-op when it never started)."""
        with self._lock:
            if self._server is None:
                return
            try:
                self._server.shutdown()
                self._server.server_close()
            finally:
                self._server = None
                if self._thread is not None:
                    self._thread.join(timeout=_METRICS_SHUTDOWN_TIMEOUT_SECONDS)
                    if self._thread.is_alive():
                        logger.warning("Prometheus metrics server thread did not exit within timeout")
                    self._thread = None
        logger.info("Prometheus metrics server stopped")

    def counter(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> PrometheusCounter:
        """Mint (or reuse) a counter registered under ``name``."""
        if name not in self._instruments:
            kwargs = {"registry": self._registry} if self._registry is not None else {}
            self._instruments[name] = PrometheusCounter(Counter(name, description, list(label_names), **kwargs))
        instrument = self._instruments[name]
        if not isinstance(instrument, PrometheusCounter):
            raise TypeError(f"Metric {name!r} is already registered as a different instrument type")
        return instrument

    def histogram(
        self,
        name: str,
        description: str,
        label_names: tuple[str, ...] = (),
        buckets: tuple[float, ...] | None = None,
    ) -> PrometheusHistogram:
        """Mint (or reuse) a histogram registered under ``name``."""
        if name not in self._instruments:
            kwargs: dict[str, Any] = {"registry": self._registry} if self._registry is not None else {}
            if buckets is not None:
                kwargs["buckets"] = list(buckets)
            self._instruments[name] = PrometheusHistogram(Histogram(name, description, list(label_names), **kwargs))
        instrument = self._instruments[name]
        if not isinstance(instrument, PrometheusHistogram):
            raise TypeError(f"Metric {name!r} is already registered as a different instrument type")
        return instrument

    def gauge(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> PrometheusGauge:
        """Mint (or reuse) a gauge registered under ``name``."""
        if name not in self._instruments:
            kwargs = {"registry": self._registry} if self._registry is not None else {}
            self._instruments[name] = PrometheusGauge(Gauge(name, description, list(label_names), **kwargs))
        instrument = self._instruments[name]
        if not isinstance(instrument, PrometheusGauge):
            raise TypeError(f"Metric {name!r} is already registered as a different instrument type")
        return instrument

counter(name, description, label_names=())

Mint (or reuse) a counter registered under name.

Source code in servicewright/adapters/observability/_metrics/prometheus.py
def counter(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> PrometheusCounter:
    """Mint (or reuse) a counter registered under ``name``."""
    if name not in self._instruments:
        kwargs = {"registry": self._registry} if self._registry is not None else {}
        self._instruments[name] = PrometheusCounter(Counter(name, description, list(label_names), **kwargs))
    instrument = self._instruments[name]
    if not isinstance(instrument, PrometheusCounter):
        raise TypeError(f"Metric {name!r} is already registered as a different instrument type")
    return instrument

gauge(name, description, label_names=())

Mint (or reuse) a gauge registered under name.

Source code in servicewright/adapters/observability/_metrics/prometheus.py
def gauge(self, name: str, description: str, label_names: tuple[str, ...] = ()) -> PrometheusGauge:
    """Mint (or reuse) a gauge registered under ``name``."""
    if name not in self._instruments:
        kwargs = {"registry": self._registry} if self._registry is not None else {}
        self._instruments[name] = PrometheusGauge(Gauge(name, description, list(label_names), **kwargs))
    instrument = self._instruments[name]
    if not isinstance(instrument, PrometheusGauge):
        raise TypeError(f"Metric {name!r} is already registered as a different instrument type")
    return instrument

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

Mint (or reuse) a histogram registered under name.

Source code in servicewright/adapters/observability/_metrics/prometheus.py
def histogram(
    self,
    name: str,
    description: str,
    label_names: tuple[str, ...] = (),
    buckets: tuple[float, ...] | None = None,
) -> PrometheusHistogram:
    """Mint (or reuse) a histogram registered under ``name``."""
    if name not in self._instruments:
        kwargs: dict[str, Any] = {"registry": self._registry} if self._registry is not None else {}
        if buckets is not None:
            kwargs["buckets"] = list(buckets)
        self._instruments[name] = PrometheusHistogram(Histogram(name, description, list(label_names), **kwargs))
    instrument = self._instruments[name]
    if not isinstance(instrument, PrometheusHistogram):
        raise TypeError(f"Metric {name!r} is already registered as a different instrument type")
    return instrument

setup(ctx)

Start the standalone exposition server when settings enable it.

Source code in servicewright/adapters/observability/_metrics/prometheus.py
def setup(self, ctx: ObsSetupContext) -> None:
    """Start the standalone exposition server when settings enable it."""
    metrics_settings = getattr(ctx.settings, "metrics", None)
    if metrics_settings is None or not getattr(metrics_settings, "enabled", False):
        return
    with self._lock:
        if self._server is not None:
            return
        app = make_wsgi_app(self._registry) if self._registry is not None else make_wsgi_app()
        # make_server (not start_http_server) keeps manual control over the
        # server lifecycle so shutdown() can stop it deterministically.
        self._server = make_server(
            host=metrics_settings.host,
            port=metrics_settings.port,
            app=app,
            handler_class=_SilentHandler,
        )
        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
        self._thread.start()
    logger.info(
        "Prometheus metrics server started",
        extra={"host": metrics_settings.host, "port": metrics_settings.port},
    )

shutdown()

Stop the exposition server (no-op when it never started).

Source code in servicewright/adapters/observability/_metrics/prometheus.py
def shutdown(self) -> None:
    """Stop the exposition server (no-op when it never started)."""
    with self._lock:
        if self._server is None:
            return
        try:
            self._server.shutdown()
            self._server.server_close()
        finally:
            self._server = None
            if self._thread is not None:
                self._thread.join(timeout=_METRICS_SHUTDOWN_TIMEOUT_SECONDS)
                if self._thread.is_alive():
                    logger.warning("Prometheus metrics server thread did not exit within timeout")
                self._thread = None
    logger.info("Prometheus metrics server stopped")

SentryErrorTrackingSink

Bases: ErrorTrackingSink

Sentry backend driven by settings.error_tracking.

Parameters:

Name Type Description Default
**init_kwargs Any

Any further sentry_sdk.init argument the settings section does not model — ignore_errors, before_send, before_send_transaction, traces_sampler, integrations, send_default_pii, ... — used through instance injection::

from servicewright.adapters.observability import SentryErrorTrackingSink

ObservabilityManager(
    ObsConfig(error_tracking="sentry"),
    error_tracking=SentryErrorTrackingSink(ignore_errors=[DomainError]),
)

The settings-driven arguments (dsn, environment, release, the sample rates, debug) stay the sink's business and are rejected here. A before_send given this way is composed with the redactor rather than replaced by it: it runs first, with the full (event, hint), and may return None to drop the event; whatever survives is redacted.

{}

Raises:

Type Description
ValueError

If init_kwargs names a settings-driven argument.

Source code in servicewright/adapters/observability/_errors/sentry.py
class SentryErrorTrackingSink(ErrorTrackingSink):
    """Sentry backend driven by ``settings.error_tracking``.

    Args:
        **init_kwargs: Any further ``sentry_sdk.init`` argument the settings
            section does not model — ``ignore_errors``, ``before_send``,
            ``before_send_transaction``, ``traces_sampler``, ``integrations``,
            ``send_default_pii``, ... — used through instance injection::

                from servicewright.adapters.observability import SentryErrorTrackingSink

                ObservabilityManager(
                    ObsConfig(error_tracking="sentry"),
                    error_tracking=SentryErrorTrackingSink(ignore_errors=[DomainError]),
                )

            The settings-driven arguments (``dsn``, ``environment``,
            ``release``, the sample rates, ``debug``) stay the sink's business
            and are rejected here. A ``before_send`` given this way is composed
            with the redactor rather than replaced by it: it runs first, with
            the full ``(event, hint)``, and may return ``None`` to drop the
            event; whatever survives is redacted.

    Raises:
        ValueError: If ``init_kwargs`` names a settings-driven argument.
    """

    backend = "sentry"

    def __init__(self, **init_kwargs: Any) -> None:
        clashing = sorted(_SETTINGS_DRIVEN_INIT_ARGS.intersection(init_kwargs))
        if clashing:
            raise ValueError(
                f"{', '.join(clashing)}: driven by settings.error_tracking and the setup context, "
                "not by SentryErrorTrackingSink arguments"
            )
        self._init_kwargs = init_kwargs
        self._initialized = False

    def setup(self, ctx: ObsSetupContext) -> None:
        """Initialize the global Sentry client (idempotent per sink instance)."""
        if self._initialized:
            return
        sentry_settings = getattr(ctx.settings, "error_tracking", None)
        if sentry_settings is None or not getattr(sentry_settings, "dsn", None):
            return

        init_kwargs = dict(self._init_kwargs)
        before_send = init_kwargs.pop("before_send", None)
        sentry_sdk.init(
            **init_kwargs,
            dsn=sentry_settings.dsn,
            environment=getattr(sentry_settings, "environment", "") or ctx.environment,
            release=ctx.app_version,
            traces_sample_rate=getattr(sentry_settings, "traces_sample_rate", 0.0),
            profiles_sample_rate=getattr(sentry_settings, "profiles_sample_rate", 0.0),
            debug=getattr(sentry_settings, "debug", False),
            before_send=_build_before_send(ctx.redactor, before_send),
        )
        self._initialized = True
        logger.info("Sentry error tracking configured", extra={"environment": ctx.environment})

    def shutdown(self) -> None:
        """Flush queued events (best-effort)."""
        if not self._initialized:
            return
        sentry_sdk.flush(timeout=_FLUSH_TIMEOUT_SECONDS)

    def reporter(self) -> SentryReporter:
        """Mint the error-reporter seam."""
        return SentryReporter()

reporter()

Mint the error-reporter seam.

Source code in servicewright/adapters/observability/_errors/sentry.py
def reporter(self) -> SentryReporter:
    """Mint the error-reporter seam."""
    return SentryReporter()

setup(ctx)

Initialize the global Sentry client (idempotent per sink instance).

Source code in servicewright/adapters/observability/_errors/sentry.py
def setup(self, ctx: ObsSetupContext) -> None:
    """Initialize the global Sentry client (idempotent per sink instance)."""
    if self._initialized:
        return
    sentry_settings = getattr(ctx.settings, "error_tracking", None)
    if sentry_settings is None or not getattr(sentry_settings, "dsn", None):
        return

    init_kwargs = dict(self._init_kwargs)
    before_send = init_kwargs.pop("before_send", None)
    sentry_sdk.init(
        **init_kwargs,
        dsn=sentry_settings.dsn,
        environment=getattr(sentry_settings, "environment", "") or ctx.environment,
        release=ctx.app_version,
        traces_sample_rate=getattr(sentry_settings, "traces_sample_rate", 0.0),
        profiles_sample_rate=getattr(sentry_settings, "profiles_sample_rate", 0.0),
        debug=getattr(sentry_settings, "debug", False),
        before_send=_build_before_send(ctx.redactor, before_send),
    )
    self._initialized = True
    logger.info("Sentry error tracking configured", extra={"environment": ctx.environment})

shutdown()

Flush queued events (best-effort).

Source code in servicewright/adapters/observability/_errors/sentry.py
def shutdown(self) -> None:
    """Flush queued events (best-effort)."""
    if not self._initialized:
        return
    sentry_sdk.flush(timeout=_FLUSH_TIMEOUT_SECONDS)

StdlibLoggingSink

Bases: LoggingSink

stdlib backend driven by settings.logging (level, JSON or plain).

Source code in servicewright/adapters/observability/_logging/stdlib.py
class StdlibLoggingSink(LoggingSink):
    """stdlib backend driven by ``settings.logging`` (level, JSON or plain)."""

    backend = "stdlib"

    def __init__(self) -> None:
        self._handler: logging.Handler | None = None

    def setup(self, ctx: ObsSetupContext) -> None:
        """Install one root handler with the configured formatter and level."""
        logging_settings = getattr(ctx.settings, "logging", None)
        if logging_settings is None or self._handler is not None:
            return

        level = _coerce_level(getattr(logging_settings, "level", "INFO"))
        use_json = bool(getattr(logging_settings, "use_json", True))

        handler = logging.StreamHandler()
        formatter: logging.Formatter = (
            _JsonFormatter(redactor=ctx.redactor) if use_json else logging.Formatter(_PLAIN_FORMAT)
        )
        handler.setFormatter(formatter)

        root = logging.getLogger()
        root.addHandler(handler)
        root.setLevel(level)
        self._handler = handler

    def shutdown(self) -> None:
        """Detach the installed handler."""
        if self._handler is None:
            return
        logging.getLogger().removeHandler(self._handler)
        self._handler = None

setup(ctx)

Install one root handler with the configured formatter and level.

Source code in servicewright/adapters/observability/_logging/stdlib.py
def setup(self, ctx: ObsSetupContext) -> None:
    """Install one root handler with the configured formatter and level."""
    logging_settings = getattr(ctx.settings, "logging", None)
    if logging_settings is None or self._handler is not None:
        return

    level = _coerce_level(getattr(logging_settings, "level", "INFO"))
    use_json = bool(getattr(logging_settings, "use_json", True))

    handler = logging.StreamHandler()
    formatter: logging.Formatter = (
        _JsonFormatter(redactor=ctx.redactor) if use_json else logging.Formatter(_PLAIN_FORMAT)
    )
    handler.setFormatter(formatter)

    root = logging.getLogger()
    root.addHandler(handler)
    root.setLevel(level)
    self._handler = handler

shutdown()

Detach the installed handler.

Source code in servicewright/adapters/observability/_logging/stdlib.py
def shutdown(self) -> None:
    """Detach the installed handler."""
    if self._handler is None:
        return
    logging.getLogger().removeHandler(self._handler)
    self._handler = None

StructlogLoggingSink

Bases: LoggingSink

structlog backend driven by settings.logging (level, JSON or console).

Source code in servicewright/adapters/observability/_logging/structlog.py
class StructlogLoggingSink(LoggingSink):
    """structlog backend driven by ``settings.logging`` (level, JSON or console)."""

    backend = "structlog"

    def __init__(self) -> None:
        self._handler: logging.Handler | None = None

    def setup(self, ctx: ObsSetupContext) -> None:
        """Configure structlog and route the root logger through its renderer."""
        logging_settings = getattr(ctx.settings, "logging", None)
        if logging_settings is None or self._handler is not None:
            return

        level = _coerce_level(getattr(logging_settings, "level", "INFO"))
        use_json = bool(getattr(logging_settings, "use_json", True))

        shared_processors: list[Any] = [
            structlog.contextvars.merge_contextvars,
            structlog.stdlib.add_logger_name,
            structlog.stdlib.add_log_level,
            structlog.processors.TimeStamper(fmt="iso", utc=True),
            # A foreign record carries its payload in ``record.__dict__``, and structlog merges it
            # only when asked to. Without this the library's own lines lose every field they carry
            # — the FastAPI access log's method/path/status, a failing warmer's name — since those
            # are all emitted as ``logger.info(..., extra={...})``.
            structlog.stdlib.ExtraAdder(),
        ]
        # After ExtraAdder on purpose: a recovered field is payload like any other and must face
        # the redactor, or a secret passed through ``extra`` would bypass it.
        if ctx.redactor is not None:
            shared_processors.append(_redaction_processor(ctx.redactor))
        shared_processors.extend(
            [
                structlog.processors.StackInfoRenderer(),
                structlog.processors.format_exc_info,
            ]
        )

        renderer = structlog.processors.JSONRenderer() if use_json else structlog.dev.ConsoleRenderer()

        structlog.configure(
            processors=[*shared_processors, structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
            logger_factory=structlog.stdlib.LoggerFactory(),
            wrapper_class=structlog.stdlib.BoundLogger,
            cache_logger_on_first_use=True,
        )

        formatter = structlog.stdlib.ProcessorFormatter(
            processors=[structlog.stdlib.ProcessorFormatter.remove_processors_meta, renderer],
            foreign_pre_chain=shared_processors,
        )
        handler = logging.StreamHandler()
        handler.setFormatter(formatter)

        root = logging.getLogger()
        root.addHandler(handler)
        root.setLevel(level)
        self._handler = handler

    def shutdown(self) -> None:
        """Detach the installed handler and reset structlog defaults."""
        if self._handler is None:
            return
        logging.getLogger().removeHandler(self._handler)
        self._handler = None
        structlog.reset_defaults()

setup(ctx)

Configure structlog and route the root logger through its renderer.

Source code in servicewright/adapters/observability/_logging/structlog.py
def setup(self, ctx: ObsSetupContext) -> None:
    """Configure structlog and route the root logger through its renderer."""
    logging_settings = getattr(ctx.settings, "logging", None)
    if logging_settings is None or self._handler is not None:
        return

    level = _coerce_level(getattr(logging_settings, "level", "INFO"))
    use_json = bool(getattr(logging_settings, "use_json", True))

    shared_processors: list[Any] = [
        structlog.contextvars.merge_contextvars,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        # A foreign record carries its payload in ``record.__dict__``, and structlog merges it
        # only when asked to. Without this the library's own lines lose every field they carry
        # — the FastAPI access log's method/path/status, a failing warmer's name — since those
        # are all emitted as ``logger.info(..., extra={...})``.
        structlog.stdlib.ExtraAdder(),
    ]
    # After ExtraAdder on purpose: a recovered field is payload like any other and must face
    # the redactor, or a secret passed through ``extra`` would bypass it.
    if ctx.redactor is not None:
        shared_processors.append(_redaction_processor(ctx.redactor))
    shared_processors.extend(
        [
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
        ]
    )

    renderer = structlog.processors.JSONRenderer() if use_json else structlog.dev.ConsoleRenderer()

    structlog.configure(
        processors=[*shared_processors, structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
        logger_factory=structlog.stdlib.LoggerFactory(),
        wrapper_class=structlog.stdlib.BoundLogger,
        cache_logger_on_first_use=True,
    )

    formatter = structlog.stdlib.ProcessorFormatter(
        processors=[structlog.stdlib.ProcessorFormatter.remove_processors_meta, renderer],
        foreign_pre_chain=shared_processors,
    )
    handler = logging.StreamHandler()
    handler.setFormatter(formatter)

    root = logging.getLogger()
    root.addHandler(handler)
    root.setLevel(level)
    self._handler = handler

shutdown()

Detach the installed handler and reset structlog defaults.

Source code in servicewright/adapters/observability/_logging/structlog.py
def shutdown(self) -> None:
    """Detach the installed handler and reset structlog defaults."""
    if self._handler is None:
        return
    logging.getLogger().removeHandler(self._handler)
    self._handler = None
    structlog.reset_defaults()

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."""
    ...

__getattr__(name)

Import a concrete backend on first access; its extra's ImportError propagates unchanged.

Source code in servicewright/adapters/observability/__init__.py
def __getattr__(name: str) -> Any:
    """Import a concrete backend on first access; its extra's ``ImportError`` propagates unchanged."""
    try:
        module_name = _BACKEND_MODULES[name]
    except KeyError:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
    return getattr(importlib.import_module(module_name, __name__), name)

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