Skip to content

Contrib

Deadline budgets

deadline-budget integration ([deadline] extra).

The core stays zero-dependency: the engine consumes the structural DeadlineSource protocol, and this module supplies sources backed by a deadline-budget BudgetContext - plus the ambient channel that carries one.

deadline-budget deliberately has no implicit context: a BudgetContext is handed from call site to call site by argument. A client engine sits far below the code that knows the budget, so the ambient channel lives here::

from clientwright.contrib.deadline import AmbientDeadlineSource, use_budget

deps = AdapterDeps(deadline_source=AmbientDeadlineSource())
client = build("httpx", config, deps)

with use_budget(BudgetContext.create(total_seconds=5.0)):
    await client.get("/users")  # runs with what is left of those 5 seconds

Without the block every call behaves exactly as before: the ambient source finds no budget and the engine falls back to the configured total.

Typed structurally on purpose - this module never imports deadline-budget, so any object with remaining()/expired() fits; the [deadline] extra exists to pin the library for services that use the real one. A ContextVar is per-task: a task started under use_budget inherits the budget (a fan-out shares one deadline), siblings do not.

AmbientDeadlineSource

DeadlineSource reading whatever use_budget installed in this task.

The right default for a long-lived client: each call picks up the budget of the request being served, and calls outside any budget run unbounded by it (only the configured total applies).

Source code in clientwright/contrib/deadline.py
class AmbientDeadlineSource:
    """DeadlineSource reading whatever ``use_budget`` installed in this task.

    The right default for a long-lived client: each call picks up the budget
    of the request being served, and calls outside any budget run unbounded
    by it (only the configured total applies).
    """

    __slots__ = ()

    def remaining(self) -> float | None:
        budget = current_budget()
        if budget is None:
            return None
        return budget.remaining()

BudgetDeadlineSource

DeadlineSource over ONE fixed budget - for a client scoped to a request.

Source code in clientwright/contrib/deadline.py
class BudgetDeadlineSource:
    """DeadlineSource over ONE fixed budget - for a client scoped to a request."""

    __slots__ = ("_budget",)

    def __init__(self, budget: DeadlineBudgetProtocol) -> None:
        self._budget = budget

    def remaining(self) -> float | None:
        return self._budget.remaining()

DeadlineBudgetProtocol

Bases: Protocol

What a source needs of a request budget: the shape of deadline_budget.BudgetContext.

Source code in clientwright/contrib/deadline.py
@runtime_checkable
class DeadlineBudgetProtocol(Protocol):
    """What a source needs of a request budget: the shape of ``deadline_budget.BudgetContext``."""

    def remaining(self) -> float:
        """Seconds left; negative once the deadline has passed."""
        ...

    def expired(self) -> bool: ...

remaining()

Seconds left; negative once the deadline has passed.

Source code in clientwright/contrib/deadline.py
def remaining(self) -> float:
    """Seconds left; negative once the deadline has passed."""
    ...

current_budget()

The budget installed for the current task, or None if there is none.

Source code in clientwright/contrib/deadline.py
def current_budget() -> DeadlineBudgetProtocol | None:
    """The budget installed for the current task, or None if there is none."""
    return _CURRENT_BUDGET.get()

use_budget(budget)

Install budget as the current one for the duration of the block.

The previous value is restored on the way out, so nesting works and an inner budget cannot outlive its block. Passing None detaches an inherited budget - for background work that must not die with the request that spawned it.

Source code in clientwright/contrib/deadline.py
@contextmanager
def use_budget(budget: DeadlineBudgetProtocol | None) -> Generator[DeadlineBudgetProtocol | None]:
    """Install ``budget`` as the current one for the duration of the block.

    The previous value is restored on the way out, so nesting works and an
    inner budget cannot outlive its block. Passing None detaches an inherited
    budget - for background work that must not die with the request that
    spawned it.
    """
    token: Token[DeadlineBudgetProtocol | None] = _CURRENT_BUDGET.set(budget)
    try:
        yield budget
    finally:
        _CURRENT_BUDGET.reset(token)

Settings

Settings models for ClientConfig ([settings] extra).

One pydantic model per config dataclass - the same field names, the same defaults - and to_config() on each, so the translation from an environment-loaded settings shape to a ClientConfig is written here, once, instead of in every service that uses the library.

Every class here is a plain BaseModel, never a BaseSettings: a section is reachable only through the settings object you nest it in, so a bare BASE_URL or TIMEOUT in a pod cannot reach it. The parent - its prefix, its nesting delimiter, its .env file - stays yours::

from pydantic_settings import BaseSettings, SettingsConfigDict

from clientwright.contrib.settings import BaseClientSettings


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_nested_delimiter="__")

    warehouse: BaseClientSettings = BaseClientSettings()


# WAREHOUSE__BASE_URL=https://wh.example.com WAREHOUSE__TIMEOUT__TOTAL=10 WAREHOUSE__POOL__HTTP2=true
config = Settings().warehouse.to_config("warehouse")

UNSET survives the trip: a knob the config leaves UNSET by default (timeout.read, pool.http2, ...) reaches the config as UNSET unless you set it, and an explicit null is the dataclass' explicit "unbounded" - the models tell the two apart with model_fields_set. Two things are not environment-shaped and are not here: service_name is the argument of :meth:BaseClientSettings.to_config, and observability.url_masker is a callable to set on the config afterwards.

BaseCircuitBreakerSettings

Bases: BaseModel

CircuitBreakerConfig as settings; key by value: origin, origin_route, origin_method.

Source code in clientwright/contrib/settings.py
class BaseCircuitBreakerSettings(BaseModel):
    """``CircuitBreakerConfig`` as settings; ``key`` by value: ``origin``, ``origin_route``, ``origin_method``."""

    fail_threshold: int = Field(default=5, ge=1, description="Consecutive tripping calls before the circuit opens")
    recovery_timeout: float = Field(default=60.0, gt=0, description="Seconds open before the next call is a probe")
    half_open_max_calls: int = Field(default=1, ge=1, description="Concurrent probes while half-open")
    max_keys: int = Field(default=512, ge=1, description="LRU cap on tracked circuits")
    key: CircuitKey = Field(default=CircuitKey.ORIGIN, description="What one circuit stands for")
    trip_kinds: frozenset[FailureKind] = Field(default=DEFAULT_TRIP_KINDS, description="Failure kinds that count")

    def to_config(self) -> CircuitBreakerConfig:
        return CircuitBreakerConfig(
            fail_threshold=self.fail_threshold,
            recovery_timeout=self.recovery_timeout,
            half_open_max_calls=self.half_open_max_calls,
            max_keys=self.max_keys,
            key=self.key,
            trip_kinds=self.trip_kinds,
        )

BaseClientSettings

Bases: BaseModel

ClientConfig as settings: the whole client, one section per sub-config.

Nest it in your own settings object; service_name is not environment-shaped and is the argument of :meth:to_config. retry and circuit_breaker are on by default and go off with retry: None = None in a subclass (or RETRY=null in the environment); proxy is off until any of its fields is set.

Source code in clientwright/contrib/settings.py
class BaseClientSettings(BaseModel):
    """``ClientConfig`` as settings: the whole client, one section per sub-config.

    Nest it in your own settings object; ``service_name`` is not environment-shaped
    and is the argument of :meth:`to_config`. ``retry`` and ``circuit_breaker``
    are on by default and go off with ``retry: None = None`` in a subclass (or
    ``RETRY=null`` in the environment); ``proxy`` is off until any of its
    fields is set.
    """

    base_url: str | None = Field(
        default=None,
        pattern=r"^https?://",
        description="Origin every relative URL joins; unset means absolute URLs per call, "
        "the only mode the requests and urllib3 adapters support",
    )
    timeout: BaseTimeoutSettings = Field(default_factory=BaseTimeoutSettings)
    pool: BasePoolSettings = Field(default_factory=BasePoolSettings)
    retry: BaseRetrySettings | None = Field(default_factory=BaseRetrySettings)
    circuit_breaker: BaseCircuitBreakerSettings | None = Field(default_factory=BaseCircuitBreakerSettings)
    tls: BaseTlsSettings = Field(default_factory=BaseTlsSettings)
    proxy: BaseProxySettings | None = None
    headers: dict[str, str] = Field(default_factory=dict, description="Headers injected with setdefault semantics")
    redirects: RedirectMode = Field(default=RedirectMode.OWNED, description="owned by the engine, or native")
    max_redirects: int = Field(default=5, ge=0, description="Hops before TooManyRedirectsError")
    caller_override: CallerOverride = Field(
        default=CallerOverride.CALLER_WINS, description="What a per-call timeout does: caller_wins, config_wins, raise"
    )
    deadline_header: str | None = Field(default=None, description="Header stamped with the remaining budget, in ms")
    observability: BaseObservabilitySettings = Field(default_factory=BaseObservabilitySettings)
    native: dict[str, dict[str, Any]] = Field(
        default_factory=dict, description="Raw passthrough: {slot: {kwarg: value}}"
    )
    on_unsupported: UnsupportedPolicy = Field(
        default=UnsupportedPolicy.WARN, description="What a knob the adapter cannot express does: ignore, warn, strict"
    )

    def to_config(self, service_name: str) -> ClientConfig:
        """The ``ClientConfig`` these settings describe, labelled ``service_name``.

        The one translation for the shipped models. ``client_config_from_settings``
        is the flat legacy route and passes a ``BaseClientSettings`` straight here.
        """
        return ClientConfig(
            service_name=service_name,
            base_url=self.base_url,
            timeout=self.timeout.to_config(),
            pool=self.pool.to_config(),
            retry=None if self.retry is None else self.retry.to_config(),
            circuit_breaker=None if self.circuit_breaker is None else self.circuit_breaker.to_config(),
            tls=self.tls.to_config(),
            proxy=None if self.proxy is None else self.proxy.to_config(),
            headers=self.headers,
            redirects=self.redirects,
            max_redirects=self.max_redirects,
            caller_override=self.caller_override,
            deadline_header=self.deadline_header,
            observability=self.observability.to_config(),
            native=NativeOptions(slots=self.native),
            on_unsupported=self.on_unsupported,
        )

to_config(service_name)

The ClientConfig these settings describe, labelled service_name.

The one translation for the shipped models. client_config_from_settings is the flat legacy route and passes a BaseClientSettings straight here.

Source code in clientwright/contrib/settings.py
def to_config(self, service_name: str) -> ClientConfig:
    """The ``ClientConfig`` these settings describe, labelled ``service_name``.

    The one translation for the shipped models. ``client_config_from_settings``
    is the flat legacy route and passes a ``BaseClientSettings`` straight here.
    """
    return ClientConfig(
        service_name=service_name,
        base_url=self.base_url,
        timeout=self.timeout.to_config(),
        pool=self.pool.to_config(),
        retry=None if self.retry is None else self.retry.to_config(),
        circuit_breaker=None if self.circuit_breaker is None else self.circuit_breaker.to_config(),
        tls=self.tls.to_config(),
        proxy=None if self.proxy is None else self.proxy.to_config(),
        headers=self.headers,
        redirects=self.redirects,
        max_redirects=self.max_redirects,
        caller_override=self.caller_override,
        deadline_header=self.deadline_header,
        observability=self.observability.to_config(),
        native=NativeOptions(slots=self.native),
        on_unsupported=self.on_unsupported,
    )

BaseObservabilitySettings

Bases: BaseModel

ObservabilityConfig as settings, minus url_masker: a callable belongs on the config.

success_log_level takes a level name (DEBUG) as well as a number.

Source code in clientwright/contrib/settings.py
class BaseObservabilitySettings(BaseModel):
    """``ObservabilityConfig`` as settings, minus ``url_masker``: a callable belongs on the config.

    ``success_log_level`` takes a level name (``DEBUG``) as well as a number.
    """

    logging: bool = Field(default=True, description="Emit log records")
    metrics: bool = Field(default=True, description="Record metrics")
    tracing: bool = Field(default=True, description="Open spans")
    success_log_level: int = Field(default=_INFO_LEVEL, description="Level of the record for a successful call")
    sensitive_query_params: frozenset[str] = Field(
        default=DEFAULT_SENSITIVE_QUERY_PARAMS, description="Query parameters redacted by name in URLs"
    )

    @field_validator("success_log_level", mode="before")
    @classmethod
    def _level_by_name(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        level = logging.getLevelNamesMapping().get(value.upper())
        if level is None:
            raise ValueError(f"unknown log level {value!r}")
        return level

    def to_config(self) -> ObservabilityConfig:
        return ObservabilityConfig(
            logging=self.logging,
            metrics=self.metrics,
            tracing=self.tracing,
            success_log_level=self.success_log_level,
            sensitive_query_params=self.sensitive_query_params,
        )

BasePoolSettings

Bases: BaseModel

PoolConfig as settings; max_connections_per_host and http2 stay UNSET until set.

Source code in clientwright/contrib/settings.py
class BasePoolSettings(BaseModel):
    """``PoolConfig`` as settings; ``max_connections_per_host`` and ``http2`` stay ``UNSET`` until set."""

    max_connections: int | None = Field(default=100, gt=0, description="Pool-wide connection cap; null = unbounded")
    max_keepalive: int | None = Field(default=20, gt=0, description="Idle connections kept open")
    keepalive_expiry: float | None = Field(default=30.0, gt=0, description="Seconds an idle connection is kept")
    max_connections_per_host: int | None = Field(default=None, gt=0, description="Per-origin connection cap")
    http2: bool | None = Field(default=None, description="HTTP/2 where the adapter can honour it")

    def to_config(self) -> PoolConfig:
        return PoolConfig(
            max_connections=self.max_connections,
            max_keepalive=self.max_keepalive,
            keepalive_expiry=self.keepalive_expiry,
            max_connections_per_host=_maybe(self, "max_connections_per_host"),
            http2=UNSET if self.http2 is None else self.http2,
        )

BaseProxySettings

Bases: BaseModel

ProxyConfig as settings: an explicit URL or the environment's proxies, not both.

Source code in clientwright/contrib/settings.py
class BaseProxySettings(BaseModel):
    """``ProxyConfig`` as settings: an explicit URL or the environment's proxies, not both."""

    url: str | None = Field(default=None, description="Explicit proxy URL")
    from_env: bool = Field(default=False, description="Read HTTP(S)_PROXY / NO_PROXY instead")

    @model_validator(mode="after")
    def _exclusive(self) -> BaseProxySettings:
        if self.url is not None and self.from_env:
            raise ValueError("proxy.url and proxy.from_env are mutually exclusive")
        return self

    def to_config(self) -> ProxyConfig:
        return ProxyConfig(url=self.url, from_env=self.from_env)

BaseRetrySettings

Bases: BaseModel

RetryConfig as settings; the sets take JSON lists, mode its value (owned / delegated).

Source code in clientwright/contrib/settings.py
class BaseRetrySettings(BaseModel):
    """``RetryConfig`` as settings; the sets take JSON lists, ``mode`` its value (``owned`` / ``delegated``)."""

    max_attempts: int = Field(default=3, ge=1, description="Attempts per redirect hop, the first included")
    initial_backoff: float = Field(default=0.1, gt=0, description="First backoff, seconds")
    max_backoff: float = Field(default=10.0, gt=0, description="Backoff ceiling, seconds")
    multiplier: float = Field(default=2.0, ge=1, description="Backoff growth per attempt")
    jitter: float = Field(default=0.2, ge=0, le=1, description="Multiplicative jitter within [0, 1]")
    retryable_kinds: frozenset[FailureKind] = Field(
        default=DEFAULT_RETRYABLE_KINDS, description="Failure kinds worth a retry"
    )
    retryable_status: frozenset[int] = Field(default=DEFAULT_RETRYABLE_STATUS, description="Statuses worth a retry")
    methods: frozenset[str] = Field(default=IDEMPOTENT_METHODS, description="Methods retried without a per-call flag")
    respect_retry_after: bool = Field(default=True, description="A Retry-After header replaces the computed backoff")
    retry_after_max: float = Field(default=60.0, description="Cap on what a server may ask for, seconds")
    budget_ratio: float | None = Field(
        default=0.1, gt=0, le=1, description="Share of an origin's traffic that may be retries; null = no budget"
    )
    require_replayable_body: bool = Field(default=True, description="Refuse to retry a body that cannot be replayed")
    mode: RetryMode = Field(default=RetryMode.OWNED, description="owned by the engine, or delegated (urllib3 only)")

    def to_config(self) -> RetryConfig:
        return RetryConfig(
            max_attempts=self.max_attempts,
            initial_backoff=self.initial_backoff,
            max_backoff=self.max_backoff,
            multiplier=self.multiplier,
            jitter=self.jitter,
            retryable_kinds=self.retryable_kinds,
            retryable_status=self.retryable_status,
            methods=self.methods,
            respect_retry_after=self.respect_retry_after,
            retry_after_max=self.retry_after_max,
            budget_ratio=self.budget_ratio,
            require_replayable_body=self.require_replayable_body,
            mode=self.mode,
        )

BaseTimeoutSettings

Bases: BaseModel

TimeoutConfig as settings.

A phase you do not set stays UNSET in the config, exactly as in TimeoutConfig(); an explicit null is the explicit "unbounded".

Source code in clientwright/contrib/settings.py
class BaseTimeoutSettings(BaseModel):
    """``TimeoutConfig`` as settings.

    A phase you do not set stays ``UNSET`` in the config, exactly as in
    ``TimeoutConfig()``; an explicit ``null`` is the explicit "unbounded".
    """

    total: float | None = Field(
        default=30.0, gt=0, description="Wall clock for the whole logical call; null = unbounded"
    )
    attempt: float | None = Field(default=None, gt=0, description="Ceiling for one attempt")
    connect: float | None = Field(default=5.0, gt=0, description="Connect phase")
    read: float | None = Field(default=None, gt=0, description="Read phase")
    write: float | None = Field(default=None, gt=0, description="Write phase")
    pool_acquire: float | None = Field(default=None, gt=0, description="Wait for a pooled connection")

    def to_config(self) -> TimeoutConfig:
        return TimeoutConfig(
            total=self.total,
            attempt=_maybe(self, "attempt"),
            connect=self.connect,
            read=_maybe(self, "read"),
            write=_maybe(self, "write"),
            pool_acquire=_maybe(self, "pool_acquire"),
        )

BaseTlsSettings

Bases: BaseModel

TlsConfig as settings; cert is a PEM path, or a JSON list of two or three paths.

Source code in clientwright/contrib/settings.py
class BaseTlsSettings(BaseModel):
    """``TlsConfig`` as settings; ``cert`` is a PEM path, or a JSON list of two or three paths."""

    verify: bool = Field(default=True, description="Verify the server certificate")
    ca_bundle: str | None = Field(default=None, description="Path to a private CA bundle")
    cert: str | tuple[str, str] | tuple[str, str, str] | None = Field(
        default=None, description="Client certificate: a PEM path, (cert, key) or (cert, key, password)"
    )

    def to_config(self) -> TlsConfig:
        return TlsConfig(verify=self.verify, ca_bundle=self.ca_bundle, cert=self.cert)

servicewright

servicewright integration ([servicewright] extra).

servicewright binds the ids of the unit of work being served (request_id, user_id, tenant_id, trace_id) into its request context, and servicewright.propagation_metadata reads them back as outbound headers. That function already satisfies clientwright's HeaderProvider protocol bare; this module names the join so no service writes it by hand::

from clientwright.contrib.servicewright import servicewright_headers

deps = AdapterDeps(header_providers=servicewright_headers())
client = build("httpx", config, deps)

await client.get("/users")  # carries x-request-id and friends, when bound

Outside a bound context the provider returns nothing and the request goes out as before. The helper returns the providers tuple rather than a whole AdapterDeps so it composes with everything else injected there - contrib.deadline included - instead of competing with it.

Unlike contrib.deadline there is nothing structural to type here; the real function is the point, so servicewright is imported at module import time and a missing extra fails right here with an install hint.

servicewright_headers()

The header providers carrying servicewright's request context upstream.

(propagation_metadata,) under servicewright's standard mapping - the request, user, tenant and trace ids as x-request-id, x-user-id, x-tenant-id and x-trace-id. For a different mapping build the tuple yourself: (partial(propagation_metadata, keys),).

Source code in clientwright/contrib/servicewright.py
def servicewright_headers() -> tuple[HeaderProvider, ...]:
    """The header providers carrying servicewright's request context upstream.

    ``(propagation_metadata,)`` under servicewright's standard mapping - the
    request, user, tenant and trace ids as ``x-request-id``, ``x-user-id``,
    ``x-tenant-id`` and ``x-trace-id``. For a different mapping build the tuple
    yourself: ``(partial(propagation_metadata, keys),)``.
    """
    return (propagation_metadata,)

Dishka

clientwright.contrib.dishka imports dishka at module import time (by design — a DI integration without the DI library is meaningless), so it is documented here rather than auto-rendered.

from clientwright.contrib.dishka import ClientwrightProvider

ClientwrightProvider(adapter, config, deps=None, *, component=None, client_type=None) — a dishka.Provider with scope=Scope.APP providing:

  • ClientRuntime — the injected deps.runtime if given, handed back as it came; else ClientRuntime.for_config(config) with the http_client_circuit_state listener wired the way an adapter wires it. One per container (per component), shared.
  • ClientHandle[Any] — an async generator provide that builds the native client with the shared runtime and closes it (aclose() / close()) in finally when the container shuts down.
  • the native client under client_type, when given — handle.client, the same object, so httpx.AsyncClient resolves to what the handle holds.

component puts all three in a Dishka component: one provider per upstream in one container, resolved with container.get(..., component="github-api") or injected as Annotated[..., FromComponent("github-api")].

Usage, scope rules and several upstreams in one container: Guide → Dependency injection.