Recipes

Small, self-contained patterns that come up in most real API clients.

Proxies

The backends have no proxy parameters on purpose: proxying is configuration of the native client, and every backend accepts one (see Backends and their configuration):

import httpx
from action0.client.backends.httpx import HttpxBackend

backend = HttpxBackend(httpx.Client(proxy="http://proxy.internal:3128"))
import requests
from action0.client.backends.requests import RequestsBackend

session = requests.Session()
session.proxies.update({"https": "http://proxy.internal:3128"})
backend = RequestsBackend(session)

The counterparts: aiohttp takes proxy= per request or trust_env=True on the session; urllib respects the HTTP(S)_PROXY environment variables by default (or takes a urllib.request.ProxyHandler); for urllib3 pass a urllib3.ProxyManager as the pool.

Metrics and tracing hooks

A Hook sees every send — including each retry attempt when the hook sits on the backend a retry wrapper wraps — which is exactly what metrics and traces want. A counter/latency hook is a few lines:

from action0.client import Hook
from action0.req import Request, Response


class MetricsHook(Hook):
    """Feeds a histogram and an error counter (prometheus-client style)."""

    def on_response(self, request: Request, response: Response, elapsed: float) -> None:
        REQUEST_SECONDS.labels(request.method, str(response.status)).observe(elapsed)

    def on_error(self, request: Request, error: BaseException, elapsed: float) -> None:
        REQUEST_ERRORS.labels(request.method, type(error).__name__).inc()

For OpenTelemetry spans, the span opened in on_request must reach on_response/on_error; carry it in request.meta (application metadata that rides along with the request and never goes on the wire — namespace your keys):

from opentelemetry import trace
from action0.client import Hook
from action0.req import Request, Response

tracer = trace.get_tracer("my-service.http")


class TracingHook(Hook):
    """One client span per send attempt."""

    def on_request(self, request: Request) -> None:
        span = tracer.start_span(f"{request.method} {request.url.hostname}")
        span.set_attribute("http.request.method", request.method)
        span.set_attribute("url.full", request.url.as_str())
        request.meta["my-service.span"] = span

    def on_response(self, request: Request, response: Response, elapsed: float) -> None:
        span = request.meta.pop("my-service.span", None)
        if span is not None:
            span.set_attribute("http.response.status_code", response.status)
            span.end()

    def on_error(self, request: Request, error: BaseException, elapsed: float) -> None:
        span = request.meta.pop("my-service.span", None)
        if span is not None:
            span.record_exception(error)
            span.end()

Correlation IDs

The same request.meta mechanism pairs a wire header with an application-side identity — set once in prepare() (or a hook), read it anywhere the request resurfaces (hooks, error handlers, logs):

import uuid
from action0.client import APIClient
from action0.req import Request


class MyAPIClient(APIClient):
    """Stamps every outgoing request with a correlation ID."""

    def prepare(self, request: Request) -> Request:
        request = super().prepare(request)  # merges the default headers
        correlation_id = str(uuid.uuid4())
        request.meta["my-service.correlation-id"] = correlation_id
        if "X-Correlation-ID" not in request.headers:
            request.headers.add("X-Correlation-ID", correlation_id)
        return request

A TransportError or APIError carries .request, so the correlation ID for the failure log is error.request.meta["my-service.correlation-id"].

Response headers in typed results

Operations usually parse the body, but load() receives the whole response — headers included. Pagination cursors, rate-limit budgets and similar header-borne data belong in the result type:

import json
from dataclasses import dataclass
from typing import Any
from action0.client import JsonOperation, query
from action0.req import Response


@dataclass
class Page:
    items: list[Any]
    next_cursor: str | None  # from a header, not the body


class ListItems(JsonOperation[Page]):
    path = "/items"
    cursor: "str | None" = query(default=None)

    def load(self, response: Response) -> Page:
        payload = json.loads(response.body_str() or "null")
        return Page(items=payload["items"], next_cursor=response.headers.get("X-Next-Cursor"))

(load() replaces the JSON decoding of JsonOperation here; for body-only results override load_json() instead, as in the operation examples in Typed API clients: operations.)