API reference

Everything public is importable from the package root:

from action0.client import Client, APIClient
from action0.client import Operation, JsonOperation
from action0.client import body, header, json_body, json_field, path_param, query
from action0.client import Backend, SyncBackend, AsyncBackend, DeferredBackend, FuturesBackend
from action0.client import BackendT_co, SendResultT_co
from action0.client import BaseSyncBackend, BaseAsyncBackend, BaseDeferredBackend
from action0.client import Hook, LoggingHook
from action0.client import (
    RetryPolicy,
    RetryingSyncBackend,
    RetryingAsyncBackend,
    RetryingDeferredBackend,
)
from action0.client import ClientError, TransportError, TimeoutError, APIError

The backend implementations and the test doubles are imported from their modules, so the optional HTTP libraries are only touched when actually used:

from action0.client.backends.requests import RequestsBackend
from action0.client.backends.httpx import HttpxBackend, AsyncHttpxBackend
from action0.client.backends.aiohttp import AiohttpBackend
from action0.client.backends.twisted import TwistedBackend
from action0.client.backends.urllib import UrllibBackend
from action0.client.backends.urllib3 import Urllib3Backend
from action0.client.backends.futures import ThreadPoolBackend
from action0.client.testing import StubBackend, AsyncStubBackend, DeferredStubBackend

Client

The generic HTTP client (Client): one class, any backend — the return type of Client.send() is exactly what the given backend’s send returns.

class action0.client.client.Client(backend)[source]

A thin, fully typed facade over a backend: Client(backend).send(request) sends a raw Request and returns the Response in whatever wrapper the backend’s execution model dictates. The wrapper type is derived from the backend (the class is generic over SendResultT_co), not enumerated anywhere — so this works for any execution model, including ones this library has never heard of:

  • Client(RequestsBackend()).send(request) is a Response,

  • await Client(AsyncHttpxBackend()).send(request) is a Response,

  • Client(TwistedBackend()).send(request) is a Deferred[Response],

  • with your own Backend[SomeWrapper[Response]], send returns a SomeWrapper[Response].

For talking to a specific API with typed operations, use APIClient instead — this class is the raw-request building block.

Example (with the test-double backend standing in for a real one):

>>> from action0.client.testing import StubBackend
>>> from action0.req import Request, Response
>>>
>>> client = Client(StubBackend(Response(204)))
>>> client.send(Request("https://api.example.com/ping")).status
204

The backend decides the execution model, the code stays the same:

>>> import asyncio
>>> from action0.client.testing import AsyncStubBackend
>>>
>>> client = Client(AsyncStubBackend(Response(204)))
>>> asyncio.run(client.send(Request("https://api.example.com/ping"))).status
204
Parameters:
  • backend (Backend[TypeVar(SendResultT_co, covariant=True)])

  • backend – the backend performing the HTTP I/O — any implementation of the Backend protocol, whatever its execution model

property backend: Backend[SendResultT_co]

The backend this client sends through, as the Backend protocol. (The client is generic over the backend’s wrapper type, not its concrete class — keep your own reference for backend-specific API like close().)

send(request)[source]

Send the request through the backend.

Parameters:

request (Request) – the request to send

Return type:

TypeVar(SendResultT_co, covariant=True)

Returns:

exactly what the backend’s send returns: the response, wrapped according to the backend’s execution model — the plain Response for a sync backend, an Awaitable[Response] for an async backend, a Deferred[Response] for a Twisted backend

Raises:

action0.client.errors.TransportError – if no response could be obtained (async-style backends deliver the error through their wrapper instead of raising here)

__repr__()[source]
Return type:

str

Returns:

the client with its backend, e.g. Client(StubBackend(0 requests))

Backend protocol and base classes

The backend abstraction: the protocol a backend implements and the base classes that make implementing one easy.

A backend is the pluggable piece that performs the actual HTTP I/O. It takes an action0.req.Request and produces an action0.req.Response — wrapped in whatever its execution model dictates: a sync backend returns the Response itself, an asyncio backend an Awaitable[Response], a Twisted backend a Deferred[Response], and a custom backend may use any other wrapper.

There is exactly one protocol, Backend, generic over that wrapper type: Backend[Response] describes sync backends, Backend[Awaitable[Response]] asyncio ones, and so on — the aliases SyncBackend, AsyncBackend and DeferredBackend name the shipped three. A backend implements the protocol purely structurally, no registration or inheritance required. Because the wrapper is the protocol’s type parameter, generic code derives its types from the backend it is given: Client.send returns exactly what the backend’s send returns — including wrapper types this library has never heard of.

The protocol has two methods:

  • send(request) performs the I/O and returns the wrapped response.

  • map(result, fn) applies a function inside the wrapper: a sync backend just calls fn(result), an async backend awaits first, a Twisted backend uses addCallback. This is the runtime composition hook that lets APIClient.send attach response parsing to a send without knowing the execution model. On the protocol it is typed loosely (Any): stating “the same wrapper, around a different value type” for an arbitrary wrapper would require higher-kinded types, which Python’s type system does not have. Implementations declare their map precisely for their own wrapper — see the base classes.

The base classes (BaseSyncBackend, BaseAsyncBackend, BaseDeferredBackend) implement send as a template around an abstract _send doing the raw I/O, and add the extension points every real-world backend ends up needing:

  • Hook instrumentation (logging, metrics, tracing, request decoration) around every send, and

  • translate_error for normalizing library-specific exceptions into the TransportError family.

The built-in backends in action0.client.backends build on them, and custom backends are encouraged to do the same — but any object with a conforming send/map pair is a backend.

class action0.client.backend.SendResultT_co

What a backend’s send returns: the response, wrapped according to the backend’s execution model — Response, Awaitable[Response], Deferred[Response], or any custom wrapper. Backend and Client are generic over it.

alias of TypeVar(‘SendResultT_co’, covariant=True)

class action0.client.backend.Backend(*args, **kwargs)[source]

The one protocol every backend implements, generic over what its send wraps the Response in — the backend’s execution model:

  • Backend[Response] — synchronous (SyncBackend)

  • Backend[Awaitable[Response]] — asyncio (AsyncBackend)

  • Backend[Deferred[Response]] — Twisted (DeferredBackend)

  • Backend[<anything else>] — your own execution model

The clients derive their send return types from this type parameter, so plugging in a different backend changes the static types without any client code changing.

send(request)[source]

Send the request.

Parameters:

request (Request) – the request to send

Return type:

TypeVar(SendResultT_co, covariant=True)

Returns:

the response, wrapped according to the execution model — e.g. returned directly (sync), as an awaitable (asyncio) or as a Deferred (Twisted)

Raises:

action0.client.errors.TransportError – if no response could be obtained (async-style backends deliver the error through their wrapper instead of raising here)

map(result, fn)[source]

Apply a function inside the wrapper: for a result that (eventually) holds a value x, return the same kind of wrapper (eventually) holding fn(x) — a plain call for sync backends, await-then-call for asyncio, addCallback for Twisted.

This is the composition hook APIClient.send uses to attach response parsing. It is typed loosely here because “the same wrapper, around a different value type” is not expressible for an arbitrary wrapper (Python has no higher-kinded types); implementations declare it precisely for their own wrapper, like the base classes do.

Parameters:
  • result (Any) – a value as returned by send()

  • fn (Callable[[Any], Any]) – the function to apply to the wrapped value

Return type:

Any

Returns:

the wrapped return value of fn

class action0.client.backend.BackendT_co

A concrete backend type; what APIClient is generic over (so client.backend keeps the concrete type). Covariant so that e.g. an APIClient[RequestsBackend] is also an APIClient[Backend[Response]] — that is what resolves the send overloads to the right wrapper.

alias of TypeVar(‘BackendT_co’, bound=Backend[Any], covariant=True)

action0.client.backend.SyncBackend

A synchronous backend: send blocks and returns the Response directly. Built-in implementations: RequestsBackend, HttpxBackend and the test double StubBackend.

alias of Backend[Response]

action0.client.backend.AsyncBackend

An asyncio backend: send returns an awaitable of the Response. Built-in implementations: AsyncHttpxBackend and the test double AsyncStubBackend.

alias of Backend[Awaitable[Response]]

action0.client.backend.DeferredBackend: TypeAlias = 'Backend[Deferred[Response]]'

A Twisted backend: send returns a Deferred firing with the Response. Built-in implementations: TwistedBackend and the test double DeferredStubBackend.

action0.client.backend.FuturesBackend

A thread-pool style backend: send returns a concurrent.futures.Future of the Response. Built-in implementation: ThreadPoolBackend.

alias of Backend[Future[Response]]

class action0.client.backend.BaseSyncBackend(hooks=())[source]

Base class for SyncBackend implementations: subclasses only implement _send() with the raw HTTP I/O and inherit the hook and error-translation plumbing.

Example — a minimal custom backend:

>>> from action0.req import Request, Response
>>> class EchoBackend(BaseSyncBackend):
...     '''Answers every request with its own URL instead of doing I/O.'''
...
...     def _send(self, request: Request) -> Response:
...         return Response(200, body=request.url.as_str(), request=request)
>>> backend = EchoBackend()
>>> backend.send(Request("https://example.com/hello")).body_str()
'https://example.com/hello'

map applies a function to a sent result — synchronously that is a plain call, but generic code uses it to stay agnostic of the execution model:

>>> backend.map(backend.send(Request("https://example.com/")), lambda r: r.status)
200
Parameters:
  • hooks (Iterable[Hook], default: ())

  • hooks – the instrumentation hooks to run around every send, in order

send(request)[source]

Send the request: run the on_request hooks, perform the I/O via _send(), and run the on_response (or, after translate_error(), the on_error) hooks.

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

the response

Raises:

BaseException – whatever translate_error returned for the exception raised while sending — a TransportError for the built-in backends

abstractmethod _send(request)[source]

Perform the actual HTTP I/O — the only method a subclass must implement. Raised exceptions are passed through translate_error().

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

the response

map(result, fn)[source]

Apply a function to a result of send() — synchronously that is simply fn(result).

Parameters:
Return type:

TypeVar(S)

Returns:

the return value of fn

class action0.client.backend.BaseAsyncBackend(hooks=())[source]

Base class for AsyncBackend implementations: subclasses only implement the coroutine _send() with the raw HTTP I/O and inherit the hook and error-translation plumbing.

Example — a minimal custom backend:

>>> import asyncio
>>> from action0.req import Request, Response
>>> class AsyncEchoBackend(BaseAsyncBackend):
...     '''Answers every request with its own URL instead of doing I/O.'''
...
...     async def _send(self, request: Request) -> Response:
...         return Response(200, body=request.url.as_str(), request=request)
>>> backend = AsyncEchoBackend()
>>> response = asyncio.run(backend.send(Request("https://example.com/hello")))
>>> response.body_str()
'https://example.com/hello'

map chains a function onto the awaitable without awaiting it first:

>>> status = backend.map(backend.send(Request("https://example.com/")), lambda r: r.status)
>>> asyncio.run(status)
200
Parameters:
  • hooks (Iterable[Hook], default: ())

  • hooks – the instrumentation hooks to run around every send, in order

async send(request)[source]

Send the request: run the on_request hooks, perform the I/O via _send(), and run the on_response (or, after translate_error(), the on_error) hooks. All hooks run inside the coroutine, i.e. once it is awaited.

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

(an awaitable of) the response

Raises:

BaseException – whatever translate_error returned for the exception raised while sending — a TransportError for the built-in backends

abstractmethod async _send(request)[source]

Perform the actual HTTP I/O — the only method a subclass must implement. Raised exceptions are passed through translate_error().

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

(an awaitable of) the response

map(result, fn)[source]

Apply a function inside an awaitable result of send(): returns a new awaitable resolving to fn of the awaited value.

Parameters:
Return type:

Awaitable[TypeVar(S)]

Returns:

an awaitable of the return value of fn

class action0.client.backend.BaseDeferredBackend(hooks=())[source]

Base class for DeferredBackend implementations: subclasses only implement _send() returning a Deferred of the response and inherit the hook and error-translation plumbing.

This class itself is importable without twisted installed (so e.g. DeferredStubBackend can always be defined); actually sending requires twisted.

Example:

from twisted.internet import reactor
from action0.client.backends.twisted import TwistedBackend
from action0.req import Request

backend = TwistedBackend()  # subclasses BaseDeferredBackend
deferred = backend.send(Request("https://example.com/"))
deferred.addCallback(lambda response: print(response.status))
Parameters:
  • hooks (Iterable[Hook], default: ())

  • hooks – the instrumentation hooks to run around every send, in order

send(request)[source]

Send the request: run the on_request hooks, start the I/O via _send(), and chain the on_response (or, after translate_error(), the on_error) hooks onto the Deferred.

Parameters:

request (Request) – the request to send

Return type:

Deferred[Response]

Returns:

a Deferred firing with the response, or failing with the translated error — a TransportError for the built-in backend

abstractmethod _send(request)[source]

Start the actual HTTP I/O — the only method a subclass must implement. Failures (and synchronously raised exceptions) are passed through translate_error().

Parameters:

request (Request) – the request to send

Return type:

Deferred[Response]

Returns:

a Deferred firing with the response

map(result, fn)[source]

Apply a function inside a Deferred result of send() — Twisted’s native addCallback.

Parameters:
Return type:

Deferred[TypeVar(S)]

Returns:

a Deferred firing with the return value of fn

Operations

The typed description of an API endpoint (Operation) and its JSON convenience subclass (JsonOperation).

An operation bundles everything about one endpoint of an API:

  • the parts that never change — the HTTP method and the path template — as class attributes,

  • the variable parts — query parameters, headers, path parameters and the body — as typed dataclass fields (placed via the specifiers of action0.client.fields),

  • and how to turn the HTTP response into a typed result — parse(), with the result type as the generic parameter.

Subclasses become keyword-only dataclasses automatically (the base class is a typing.dataclass_transform()), so an operation is declared like a dataclass and instantiated like one:

class GetItem(JsonOperation[Item]):
    method = Method.GET
    path = "/items/{item_id}"

    item_id: int = path_param()
    expand: bool | None = query(default=None)

    def load_json(self, data: Any) -> Item:
        return Item(id=data["id"], name=data["name"])


operation = GetItem(item_id=42)

An APIClient turns operations into requests, sends them through its backend and parses the responses — with the parsed type flowing through: client.send(GetItem(item_id=42)) is an Item (or an Awaitable[Item] / Deferred[Item], depending on the backend).

class action0.client.operation.R_co

The parsed result type of an operation — what Operation.parse() returns and what action0.client.api.APIClient.send() resolves to.

alias of TypeVar(‘R_co’, covariant=True)

class action0.client.operation.Operation[source]

The base class of all endpoint descriptions.

Subclassing does three things automatically:

  • the subclass becomes a keyword-only dataclasses.dataclass() (fields are declared with the specifiers of action0.client.fields, or plainly — then default_location decides their placement),

  • the class is validated: every {placeholder} of the path template must have exactly one matching path_param() field, only one form of request body may be declared, and reserved names are refused,

  • instances gain dataclass __init__, __eq__ and __repr__.

Subclasses choose the parsed result type via the generic parameter and implement load() (or use JsonOperation, which implements it for JSON APIs). The class attributes fix the constant parts of the endpoint:

  • method — the HTTP method (default GET),

  • path — the path template appended to the client’s base URL, with {placeholder} names bound to path_param() fields,

  • accept — an Accept header value to request,

  • default_location — where fields without an explicit specifier go (query parameters by default; a JSON-body-heavy API family may want Location.JSON_FIELD).

Example — a raw (non-JSON) operation returning the body text:

>>> from action0.req import Method, Response
>>> class GetReport(Operation[str]):
...     method = Method.GET
...     path = "/reports/{report_id}"
...
...     report_id: int = path_param()
...     lines: int | None = query(default=None)
...
...     def load(self, response: Response) -> str:
...         return response.body_str() or ""
>>> operation = GetReport(report_id=7, lines=100)
>>> operation
GetReport(report_id=7, lines=100)
>>> operation.as_request("https://api.example.com/v1").url.as_str()
'https://api.example.com/v1/reports/7?lines=100'
>>> operation.parse(Response(200, body="all is well"))
'all is well'

Fields whose value is None are omitted from the request:

>>> GetReport(report_id=7).as_request("https://api.example.com/v1").url.as_str()
'https://api.example.com/v1/reports/7'

Unexpected statuses raise an APIError (tune that by overriding check()):

>>> operation.parse(Response(500, body="boom"))
Traceback (most recent call last):
    ...
action0.client.errors.APIError: GetReport: unexpected status 500 Internal Server Error
method: ClassVar[str] = 'GET'

The HTTP method of the endpoint — fixed per operation class.

path: ClassVar[str] = ''

The path template of the endpoint, appended to the client’s base URL. {placeholder} names are filled from the operation’s path_param() fields. A leading / is optional — the path is always joined to the base URL with exactly one /.

accept: ClassVar[str | None] = None

The Accept header to send, unless one is set explicitly; None sends none. JsonOperation sets application/json.

default_location: ClassVar[Location] = 'query'

Where fields without an explicit specifier are placed. Query parameters by default; an API family whose endpoints all take JSON bodies may set this to JSON_FIELD in its common base class.

as_request(base_url=None)[source]

Build the Request this operation describes: the rendered path template appended to the base URL, the query/header/body fields serialized into their places (fields whose value is None are omitted), plus a Content-Type for a JSON or form body and the accept header — each only if not already set.

Called by action0.client.api.APIClient.send(), but also useful standalone, e.g. in tests. Override it (adjusting the result of super().as_request(base_url)) for exotic request shapes.

Parameters:

base_url (str | Url | None, default: None) – the URL the endpoint path is appended to, e.g. "https://api.example.com/v2"; None builds a relative request (handy for inspecting)

Return type:

Request

Returns:

the request, ready to be sent through a backend

Raises:

ValueError – if a path parameter is None, or a value cannot be serialized for its location

serialize_value(value)[source]

Serialize a field value for a query parameter or header: enums become their value, dates/times their ISO representation, scalars pass through (the Params/Headers classes coerce them, e.g. True to "true"), and a list/tuple/set becomes a list — one query parameter / header line per element.

Override to support more value types across an operation family.

Parameters:

value (Any) – the field value (never None — those are omitted)

Return type:

str | int | float | bool | list[str | int | float | bool]

Returns:

the scalar (or list of scalars) to put on the wire

Raises:

ValueError – if the value (or an element) is no scalar

serialize_json_value(value)[source]

Serialize a field value for a JSON body: enums become their value, dates/times their ISO representation, dataclasses and mappings become objects (entries whose value is None are omitted, like everywhere else), lists/tuples/sets become arrays, scalars and None pass through.

Override to support more value types across an operation family.

Parameters:

value (Any) – the field value

Return type:

Any

Returns:

something json.dumps() can encode

Raises:

ValueError – if the value (or a part of it) has no JSON representation

parse(response)[source]

Turn the HTTP response into the operation’s typed result: check() the status, then load() the payload. This is what action0.client.api.APIClient.send() attaches to the backend’s result via map.

Parameters:

response (Response) – the response the backend produced

Return type:

TypeVar(R_co, covariant=True)

Returns:

the parsed result

Raises:

action0.client.errors.APIError – if the response is not usable (unexpected status, malformed payload, …)

check(response)[source]

Verify the response is one this operation can load — by default any 2xx passes and everything else raises. Override for per-status handling (say, mapping 404 to a domain exception, or accepting 3xx).

Parameters:

response (Response) – the response the backend produced

Raises:

action0.client.errors.APIError – if the status is not 2xx

Return type:

None

abstractmethod load(response)[source]

Turn a checked response into the typed result — the one method a concrete operation must provide (JsonOperation implements it for JSON payloads).

Parameters:

response (Response) – the response, already vetted by check()

Return type:

TypeVar(R_co, covariant=True)

Returns:

the parsed result

Raises:

action0.client.errors.APIError – if the payload cannot be parsed

class action0.client.operation.JsonOperation[source]

An Operation against a JSON endpoint: requests advertise Accept: application/json, and load() decodes the response body as JSON before handing it to load_json().

Used directly with Any (or a JSON-ish alias) as result type, the decoded payload comes back as-is:

>>> from typing import Any
>>> from action0.req import Method, Response
>>> class SearchItems(JsonOperation[Any]):
...     path = "/items"
...     q: str = query()
>>> operation = SearchItems(q="thing")
>>> operation.as_request("https://api.example.com").url.as_str()
'https://api.example.com/items?q=thing'
>>> operation.parse(Response(200, body='{"hits": 2}'))
{'hits': 2}

For a typed result, choose the result type and override load_json():

>>> from dataclasses import dataclass
>>> @dataclass
... class Item:
...     id: int
...     name: str
>>> class GetItem(JsonOperation[Item]):
...     path = "/items/{item_id}"
...     item_id: int = path_param()
...
...     def load_json(self, data: Any) -> Item:
...         return Item(id=data["id"], name=data["name"])
>>> GetItem(item_id=1).parse(Response(200, body='{"id": 1, "name": "Thing"}'))
Item(id=1, name='Thing')

Sending a JSON body is a matter of field specifiers, not of this class — see json_field() / json_body():

>>> class CreateItem(JsonOperation[Item]):
...     method = Method.POST
...     path = "/items"
...
...     name: str = json_field()
...     tags: list[str] | None = json_field(default=None)
...
...     def load_json(self, data: Any) -> Item:
...         return Item(id=data["id"], name=data["name"])
>>> request = CreateItem(name="Thing").as_request("https://api.example.com")
>>> request.body
'{"name": "Thing"}'
>>> request.headers["Content-Type"]
'application/json'
accept: ClassVar[str | None] = 'application/json'

The Accept header to send, unless one is set explicitly; None sends none. JsonOperation sets application/json.

load(response)[source]

Decode the response body as JSON and delegate to load_json().

Parameters:

response (Response) – the response, already vetted by check()

Return type:

TypeVar(R_co, covariant=True)

Returns:

the parsed result

Raises:

action0.client.errors.APIError – if the body is empty or no valid JSON

load_json(data)[source]

Turn the decoded JSON payload into the typed result. The default returns the payload unchanged — which is only type-correct for JsonOperation[Any] (or a JSON-ish result type); override it whenever the result type is a real model.

Parameters:

data (Any) – the decoded JSON payload

Return type:

TypeVar(R_co, covariant=True)

Returns:

the parsed result

Raises:

action0.client.errors.APIError – if the payload does not have the expected shape

Field specifiers

The field specifiers of Operation: they declare where in the HTTP request an operation field goes.

An operation is a dataclass; its fields describe the variable parts of the endpoint. Each field is placed into the request according to its specifier:

class SearchItems(JsonOperation[Any]):
    method = Method.GET
    path = "/items/{shelf}"

    shelf: str = path_param()  # into the path template
    q: str = query()  # ?q=...
    page_size: int = query("pageSize", default=25)  # renamed on the wire
    locale: str | None = header("Accept-Language", default=None)


class CreateItem(JsonOperation[Any]):
    method = Method.POST
    path = "/items"

    name: str = json_field()  # key in the JSON body object
    tags: list[str] = json_field(default_factory=list)

A field without a specifier uses the operation’s default_location (query parameters unless a subclass overrides it), so simple query-only operations need no specifiers at all. Fields whose value is None are omitted from the request everywhere.

The specifiers are dataclass_transform field specifiers: type checkers understand default / default_factory exactly like in dataclasses.field(). (The wire-name parameter is called name, not alias, on purpose — PEP 681 reserves alias for renaming the __init__ parameter, which is not what a wire name means.)

class action0.client.fields.Location(*values)[source]

Where in the HTTP request an operation field is placed.

QUERY = 'query'

A query parameter (?name=value).

HEADER = 'header'

A header field.

PATH = 'path'

A value for a {placeholder} in the operation’s path template.

JSON_FIELD = 'json-field'

A key of the JSON object sent as the request body.

JSON_BODY = 'json-body'

The entire request body, serialized as JSON.

FORM_FIELD = 'form-field'

A key of the application/x-www-form-urlencoded request body.

BODY = 'body'

The entire request body, raw: bytes, str or a BodyProducer.

class action0.client.fields.FieldSpec(location, alias=None, serialize=None)[source]

The request-placement description attached to an operation field — what the specifier functions of this module produce (in the field metadata under "action0-client").

Parameters:
location: Location

Where in the request the field goes.

alias: str | None = None

The name on the wire (query parameter name, header name, JSON key); None uses the field name.

serialize: Callable[[Any], Any] | None = None

A custom serializer applied to the value before the standard value coercion; None uses the standard coercion only.

action0.client.fields.query(name=None, *, default=<dataclasses._MISSING_TYPE object>, default_factory=<dataclasses._MISSING_TYPE object>, serialize=None, repr=True)[source]

Declare an operation field sent as a query parameter.

A list/tuple/set value produces one name=value pair per element; enums are sent as their value; None omits the parameter.

Parameters:
  • name (str | None, default: None) – the parameter name on the wire; None uses the field name

  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • default_factory (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the default (for mutable defaults like lists)

  • serialize (Callable[[Any], Any] | None, default: None) – a custom serializer applied to the value first

  • repr (bool, default: True) – whether the field shows up in the operation’s repr()

Return type:

Any

Returns:

the dataclass field

action0.client.fields.header(name=None, *, default=<dataclasses._MISSING_TYPE object>, default_factory=<dataclasses._MISSING_TYPE object>, serialize=None, repr=True)[source]

Declare an operation field sent as a header.

Since field names cannot contain -, most header fields want an alias: token: str = header("X-API-Key", repr=False). A list value produces one header line per element; None omits the header.

Parameters:
  • name (str | None, default: None) – the header name on the wire; None uses the field name

  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • default_factory (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the default

  • serialize (Callable[[Any], Any] | None, default: None) – a custom serializer applied to the value first

  • repr (bool, default: True) – whether the field shows up in the operation’s repr() — pass False for credentials

Return type:

Any

Returns:

the dataclass field

action0.client.fields.path_param(*, default=<dataclasses._MISSING_TYPE object>, serialize=None, repr=True)[source]

Declare an operation field filling the {placeholder} of the same name in the operation’s path template. The value must serialize to a single scalar and (unlike everywhere else) must not be None.

Parameters:
  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • serialize (Callable[[Any], Any] | None, default: None) – a custom serializer applied to the value first

  • repr (bool, default: True) – whether the field shows up in the operation’s repr()

Return type:

Any

Returns:

the dataclass field

action0.client.fields.json_field(name=None, *, default=<dataclasses._MISSING_TYPE object>, default_factory=<dataclasses._MISSING_TYPE object>, serialize=None, repr=True)[source]

Declare an operation field sent as one key of the JSON object request body. All json_field fields of an operation together form that object; None values are omitted. Cannot be combined with json_body() or body().

Parameters:
  • name (str | None, default: None) – the JSON key on the wire; None uses the field name

  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • default_factory (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the default (for mutable defaults like lists)

  • serialize (Callable[[Any], Any] | None, default: None) – a custom serializer applied to the value first

  • repr (bool, default: True) – whether the field shows up in the operation’s repr()

Return type:

Any

Returns:

the dataclass field

action0.client.fields.form_field(name=None, *, default=<dataclasses._MISSING_TYPE object>, default_factory=<dataclasses._MISSING_TYPE object>, serialize=None, repr=True)[source]

Declare an operation field sent as one key of an application/x-www-form-urlencoded request body — the classic HTML form POST (and the shape of OAuth token endpoints). All form_field fields of an operation together form that body; values serialize like query parameters (a list produces one name=value pair per element, None omits the key). Cannot be combined with the JSON body specifiers or body().

Parameters:
  • name (str | None, default: None) – the form key on the wire; None uses the field name

  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • default_factory (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the default (for mutable defaults like lists)

  • serialize (Callable[[Any], Any] | None, default: None) – a custom serializer applied to the value first

  • repr (bool, default: True) – whether the field shows up in the operation’s repr() — pass False for credentials (e.g. OAuth client secrets)

Return type:

Any

Returns:

the dataclass field

action0.client.fields.json_body(*, default=<dataclasses._MISSING_TYPE object>, default_factory=<dataclasses._MISSING_TYPE object>, serialize=None, repr=True)[source]

Declare an operation field sent as the entire request body, serialized as JSON (dataclasses, mappings, sequences, enums, dates and scalars all work — see serialize_json_value()). At most one per operation; cannot be combined with json_field() or body().

Parameters:
  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • default_factory (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the default

  • serialize (Callable[[Any], Any] | None, default: None) – a custom serializer applied to the value first

  • repr (bool, default: True) – whether the field shows up in the operation’s repr()

Return type:

Any

Returns:

the dataclass field

action0.client.fields.body(*, default=<dataclasses._MISSING_TYPE object>, default_factory=<dataclasses._MISSING_TYPE object>, repr=True)[source]

Declare an operation field sent as the entire request body, raw. The value must be bytes, str or a streaming BodyProducer (i.e. an action0.req.body.BodyTypes). At most one per operation; cannot be combined with the JSON body specifiers. Remember to also declare a Content-Type (e.g. via a header() field or the client’s default headers).

Parameters:
  • default (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is required

  • default_factory (Any, default: <dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the default

  • repr (bool, default: True) – whether the field shows up in the operation’s repr()

Return type:

Any

Returns:

the dataclass field

APIClient

The generic API client (APIClient): binds a backend, a base URL and default headers, and sends typed Operation instances.

class action0.client.api.R

The parsed result type of the operation being sent.

alias of TypeVar(‘R’)

class action0.client.api.APIClient(backend, base_url, *, headers=None)[source]

A client for one HTTP API: it holds the backend, the base URL and the default headers, and send() turns an Operation into a request, sends it and parses the response — through Operation.parse, attached via the backend’s map.

The result type follows the operation and the backend: for an Operation[Item], send returns

  • Item with a sync backend,

  • Awaitable[Item] with an async backend,

  • Deferred[Item] with a Twisted backend

— and the type checker knows it. The same client class serves every execution model; only the backend changes. (The three shipped models are typed precisely; a backend with any other wrapper type works the same way at runtime, its send result is just typed Any — see send().)

Example (with the test-double backend standing in for a real one):

>>> from typing import Any
>>> from action0.client import JsonOperation, query
>>> from action0.client.testing import StubBackend
>>> from action0.req import Response
>>>
>>> class SearchItems(JsonOperation[Any]):
...     path = "/items"
...     q: str = query()
>>>
>>> backend = StubBackend(Response(200, body='{"hits": 2}'))
>>> client = APIClient(backend, "https://api.example.com/v1")
>>> client.send(SearchItems(q="thing"))
{'hits': 2}
>>> backend.requests[0].url.as_str()
'https://api.example.com/v1/items?q=thing'

The same operations sent asynchronously — only the backend differs:

>>> import asyncio
>>> from action0.client.testing import AsyncStubBackend
>>>
>>> client = APIClient(AsyncStubBackend(Response(200, body="[]")), "https://api.example.com/v1")
>>> asyncio.run(client.send(SearchItems(q="thing")))
[]

Real API clients usually subclass, fixing base URL and auth (keep the backend type variable so the typed overloads keep working):

class ExampleClient(APIClient[BackendT_co]):
    def __init__(self, backend: BackendT_co, token: str) -> None:
        super().__init__(
            backend,
            "https://api.example.com/v1",
            headers={"Authorization": f"Bearer {token}"},
        )
Parameters:
base_url

The URL every operation path is appended to.

headers

The default headers, added to requests that don’t set them.

property backend: BackendT_co

The backend this client sends through (as its concrete type).

prepare(request)[source]

Last touches before a request is sent: the default headers are added — per header field, only if the request does not set that field itself.

Override this for dynamic per-request work like signing or token refresh (call super().prepare(request) to keep the default-header behavior):

class SignedClient(APIClient[BackendT_co]):
    def prepare(self, request: Request) -> Request:
        request = super().prepare(request)
        request.headers["X-Signature"] = self._sign(request)
        return request
Parameters:

request (Request) – the request built from an operation

Return type:

Request

Returns:

the request to actually send

send(operation)[source]

Send an operation: build its request (Operation.as_request with this client’s base URL, then prepare()), send it through the backend, and parse the response via Operation.parse — attached with the backend’s map, so it runs inside whatever wrapper the backend returns.

Overloads:
  • self (APIClient[Backend[Response]]), operation (Operation[R]) → R

  • self (APIClient[Backend[Deferred[Response]]]), operation (Operation[R]) → Deferred[R]

  • self (APIClient[Backend[Awaitable[Response]]]), operation (Operation[R]) → Awaitable[R]

  • self (APIClient[Backend[Future[Response]]]), operation (Operation[R]) → Future[R]

  • self, operation (Operation[R]) → Any

Parameters:

operation (Operation[Any]) – the operation to execute

Returns:

the parsed result, wrapped according to the backend’s execution model: plain for a sync backend, awaitable for an async backend, a Deferred for a Twisted backend, a Future for a thread-pool backend — those four are typed precisely; any other execution model works the same way but is typed Any (for precise typing of a custom wrapper, subclass and re-declare send — see the Other execution models section of the guide)

Raises:

action0.client.errors.ClientError – transport failures and response parsing failures (for async and Twisted backends they arrive at await time / in the errback instead of being raised here)

__repr__()[source]
Return type:

str

Returns:

the client with its base URL and backend, e.g. APIClient(https://api.example.com/v1 via StubBackend())

Hooks

Instrumentation hooks observing (and adjusting) the requests a backend sends.

class action0.client.hooks.Hook[source]

The instrumentation interface of the backend base classes: every backend built on BaseSyncBackend, BaseAsyncBackend or BaseDeferredBackend calls its hooks around every send — for logging, metrics, tracing, request decoration, …

All three methods are no-ops here; subclass and override what you need. The methods are plain synchronous calls in every execution model (they run around the I/O, never inside it), so one hook implementation works with sync, async and Twisted backends alike.

Example — a metrics hook counting responses by status:

class StatusMetricsHook(Hook):
    def __init__(self) -> None:
        self.counts: dict[int, int] = {}

    def on_response(self, request, response, elapsed):
        self.counts[response.status] = self.counts.get(response.status, 0) + 1
        return None
on_request(request)[source]

Called before the request is sent.

Parameters:

request (Request) – the request about to be sent

Return type:

Request | None

Returns:

a replacement request, or None to send the given one (mutating the given request also works — it is the one that will be sent)

on_response(request, response, elapsed)[source]

Called after a response arrived, before it is handed to the caller.

Parameters:
  • request (Request) – the request that was sent

  • response (Response) – the response that arrived

  • elapsed (float) – the seconds between sending and the response’s arrival

Return type:

Response | None

Returns:

a replacement response, or None to keep the given one

on_error(request, error, elapsed)[source]

Called when sending failed — after the backend translated the error (see translate_error()), right before it is raised. Purely observational: hooks cannot swallow or replace errors.

Parameters:
  • request (Request) – the request that was sent

  • error (BaseException) – the (translated) error about to be raised

  • elapsed (float) – the seconds between sending and the failure

Return type:

None

class action0.client.hooks.LoggingHook(logger=None, level=10, error_level=30)[source]

A ready-made Hook that logs every request, response and error. Requests and responses are logged via their repr(), which redacts secret header values and passwords — safe for production logs.

Example:

>>> import logging, sys
>>> logger = logging.getLogger("docs.logging-hook")
>>> logger.propagate = False
>>> logger.setLevel(logging.DEBUG)
>>> logger.addHandler(logging.StreamHandler(sys.stdout))
>>> from action0.client.testing import StubBackend
>>> from action0.req import Request, Response
>>>
>>> backend = StubBackend(Response(200), hooks=[LoggingHook(logger)])
>>> response = backend.send(Request("https://example.com/health"))
-> Request(GET https://example.com/health)
<- Response(200 OK) for Request(GET https://example.com/health) in 0ms
Parameters:
  • logger (Logger | None, default: None)

  • level (int, default: 10)

  • error_level (int, default: 30)

  • logger – the logger to log to; defaults to the logger named like this module (action0.client.hooks)

  • level – the level for request and response lines

  • error_level – the level for error lines

on_request(request)[source]

Log the request (redacted via repr()).

Parameters:

request (Request) – the request about to be sent

Return type:

Request | None

Returns:

always None — the request is only observed

on_response(request, response, elapsed)[source]

Log the response with its round-trip time (redacted via repr()).

Parameters:
  • request (Request) – the request that was sent

  • response (Response) – the response that arrived

  • elapsed (float) – the seconds between sending and the response’s arrival

Return type:

Response | None

Returns:

always None — the response is only observed

on_error(request, error, elapsed)[source]

Log the error with the request that caused it.

Parameters:
  • request (Request) – the request that was sent

  • error (BaseException) – the (translated) error about to be raised

  • elapsed (float) – the seconds between sending and the failure

Return type:

None

Retries

Backend-agnostic retries: wrap any backend in the retrying variant of its execution model and failed sends are repeated with exponential backoff.

Retrying is a wrapper, not a Hook: hooks observe a send, retrying has to perform new ones. The wrappers preserve the wrapped backend’s execution model — and with it the static types (Client/APIClient treat a RetryingSyncBackend exactly like any other sync backend) — and the wrapped backend’s hooks run on every attempt, so logs and metrics see the retries.

What counts as retryable is the RetryPolicy’s call: by default, transport errors and typical transient statuses (408, 429, 5xx gateway family), for idempotent methods only. When the attempts are exhausted, the last response is returned (or the last error raised) as-is — the policy never invents failures.

The waits apply “full jitter” by default — each one is a uniformly random fraction of the exponential delay, so a burst of failing clients does not retry in lockstep — and honor a Retry-After response header (both the seconds and the HTTP-date form), capped at the policy’s max_backoff.

action0.client.retry.IDEMPOTENT_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'})

The HTTP methods that are safe to repeat per RFC 9110 — the default method gate of RetryPolicy.

class action0.client.retry.RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({408, 429, 500, 502, 503, 504}), retry_errors=(<class 'action0.client.errors.TransportError'>, ), methods=frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object>)[source]

When and how to retry — immutable, shared freely between backends.

Example: five attempts, snappier backoff, POST included:

RetryPolicy(attempts=5, backoff=0.1, methods=None)
Parameters:
  • attempts (int, default: 3)

  • backoff (float, default: 0.5)

  • multiplier (float, default: 2.0)

  • max_backoff (float, default: 30.0)

  • retry_statuses (frozenset[int], default: frozenset({500, 408, 502, 503, 504, 429}))

  • retry_errors (tuple[type[BaseException], ...], default: (<class 'action0.client.errors.TransportError'>,))

  • methods (frozenset[str] | None, default: frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}))

  • jitter (bool, default: True)

  • respect_retry_after (bool, default: True)

  • rng (Callable[[], float], default: <built-in method random of Random object at 0x3ae5ee70>)

attempts: int = 3

The total number of tries, including the first one.

backoff: float = 0.5

The seconds to wait before the second attempt; subsequent waits grow by multiplier.

multiplier: float = 2.0

The exponential backoff factor.

max_backoff: float = 30.0

The ceiling for a single wait, in seconds.

retry_statuses: frozenset[int] = frozenset({408, 429, 500, 502, 503, 504})

The response statuses considered transient.

retry_errors: tuple[type[BaseException], ...] = (<class 'action0.client.errors.TransportError'>,)

The exception types considered transient. Backends translate their library’s network failures into TransportError, so the default covers connection failures and timeouts of every backend.

methods: frozenset[str] | None = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'})

The methods that may be retried at all; None allows every method (only do that for APIs whose non-idempotent endpoints tolerate replays).

jitter: bool = True

Whether to apply “full jitter”: each wait becomes a uniformly random duration between zero and the exponential delay, so many clients failing together do not retry in lockstep. False waits the exact exponential delays.

respect_retry_after: bool = True

Whether a Retry-After response header overrides the computed backoff (jitter included) — the server knows best when it is worth coming back. Its value is still capped at max_backoff.

rng()

The random source for the jitter, returning floats in [0, 1) (injectable for deterministic tests).

delay_for(attempt, response=None)[source]

The seconds to wait after the given (1-based) attempt failed.

A parseable Retry-After header on the response wins over the computed backoff (if respect_retry_after); otherwise the delay is exponential, jittered per jitter. Both are capped at max_backoff.

Parameters:
  • attempt (int) – the attempt that just failed

  • response (Response | None, default: None) – the response that triggered the retry, if the attempt produced one

Return type:

float

Returns:

the wait in seconds

applies_to(request)[source]

Whether the request’s method may be retried at all.

Parameters:

request (Request) – the request being sent

Return type:

bool

Returns:

whether retrying is allowed for this request

should_retry_response(request, response, attempt)[source]

Whether a received response should be thrown away and retried.

Parameters:
  • request (Request) – the request that was sent

  • response (Response) – the response that arrived

  • attempt (int) – the (1-based) attempt that produced it

Return type:

bool

Returns:

whether to retry

should_retry_error(request, error, attempt)[source]

Whether a failed send should be retried.

Parameters:
  • request (Request) – the request that was sent

  • error (BaseException) – the (already translated) error it failed with

  • attempt (int) – the (1-based) attempt that failed

Return type:

bool

Returns:

whether to retry

class action0.client.retry.RetryingSyncBackend(inner, policy=RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({500, 408, 502, 503, 504, 429}), retry_errors=(<class 'action0.client.errors.TransportError'>, ), methods=frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object>), *, sleep=<built-in function sleep>)[source]

A retrying wrapper around a synchronous backend — itself a Backend[Response], so it plugs into the clients like the backend it wraps.

Example:

>>> from action0.client import Client, RetryPolicy
>>> from action0.client.testing import StubBackend
>>> from action0.req import Request, Response
>>>
>>> flaky = StubBackend(Response(503), Response(503), Response(200, body="finally"))
>>> policy = RetryPolicy(attempts=3, backoff=0)  # no waiting, for the example
>>> backend = RetryingSyncBackend(flaky, policy)
>>> Client(backend).send(Request("https://api.example.com/")).body_str()
'finally'
>>> len(flaky.requests)
3
Parameters:
  • inner (Backend[Response])

  • policy (RetryPolicy, default: RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({500, 408, 502, 503, 504, 429}), retry_errors=(<class 'action0.client.errors.TransportError'>,), methods=frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object at 0x3ae5ee70>))

  • sleep (Callable[[float], None], default: <built-in function sleep>)

  • inner – the backend that actually sends

  • policy – when and how to retry

  • sleep – the wait function for the backoff (injectable for tests)

property inner: Backend[Response]

The wrapped backend doing the actual sends.

send(request)[source]

Send with retries: transient failures (per the policy) are retried after an exponential backoff; the final outcome is returned or raised as-is.

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

the response of the last attempt

Raises:

BaseException – the error of the last attempt

map(result, fn)[source]

Apply a function to a result of send() — synchronously that is simply fn(result).

Parameters:
Return type:

TypeVar(S)

Returns:

the return value of fn

class action0.client.retry.RetryingAsyncBackend(inner, policy=RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({500, 408, 502, 503, 504, 429}), retry_errors=(<class 'action0.client.errors.TransportError'>, ), methods=frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object>), *, sleep=None)[source]

A retrying wrapper around an async backend — itself a Backend[Awaitable[Response]], so it plugs into the clients like the backend it wraps. The backoff waits with asyncio.sleep() by default; under trio, pass sleep=trio.sleep.

Parameters:
  • inner (Backend[Awaitable[Response]])

  • policy (RetryPolicy, default: RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({500, 408, 502, 503, 504, 429}), retry_errors=(<class 'action0.client.errors.TransportError'>,), methods=frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object at 0x3ae5ee70>))

  • sleep (Callable[[float], Awaitable[None]] | None, default: None)

  • inner – the backend that actually sends

  • policy – when and how to retry

  • sleep – the awaitable wait function for the backoff; None uses asyncio.sleep() (pass trio.sleep on trio)

property inner: Backend[Awaitable[Response]]

The wrapped backend doing the actual sends.

async send(request)[source]

Send with retries: transient failures (per the policy) are retried after an exponential backoff; the final outcome is returned or raised as-is.

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

(an awaitable of) the response of the last attempt

Raises:

BaseException – the error of the last attempt, at await time

map(result, fn)[source]

Apply a function inside an awaitable result of send().

Parameters:
Return type:

Awaitable[TypeVar(S)]

Returns:

an awaitable of the return value of fn

class action0.client.retry.RetryingDeferredBackend(inner, policy=RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({500, 408, 502, 503, 504, 429}), retry_errors=(<class 'action0.client.errors.TransportError'>, ), methods=frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object>), *, reactor=None)[source]

A retrying wrapper around a Twisted backend — itself a Backend[Deferred[Response]], so it plugs into the clients like the backend it wraps. The backoff waits via twisted.internet.task.deferLater() on the given reactor (or the global one).

Parameters:
  • inner (Backend[Deferred[Response]])

  • policy (RetryPolicy, default: RetryPolicy(attempts=3, backoff=0.5, multiplier=2.0, max_backoff=30.0, retry_statuses=frozenset({500, 408, 502, 503, 504, 429}), retry_errors=(<class 'action0.client.errors.TransportError'>,), methods=frozenset({'TRACE', 'OPTIONS', 'PUT', 'GET', 'DELETE', 'HEAD'}), jitter=True, respect_retry_after=True, rng=<built-in method random of Random object at 0x3ae5ee70>))

  • reactor (Any, default: None)

  • inner – the backend that actually sends

  • policy – when and how to retry

  • reactor – the clock for the backoff timer; None uses the global reactor (imported lazily on the first send, not at construction)

property inner: Backend[Deferred[Response]]

The wrapped backend doing the actual sends.

send(request)[source]

Send with retries: transient failures (per the policy) are retried after an exponential backoff; the final outcome fires (or fails) the returned Deferred as-is.

Parameters:

request (Request) – the request to send

Return type:

Deferred[Response]

Returns:

a Deferred firing with the response of the last attempt

map(result, fn)[source]

Apply a function inside a Deferred result of send() — Twisted’s native addCallback.

Parameters:
Return type:

Deferred[TypeVar(S)]

Returns:

a Deferred firing with the return value of fn

Caching

Explicit, TTL-based response caching: wrap a backend in the caching variant of its execution model and repeated safe requests are served from the cache instead of the network.

This is deliberately not an RFC 9111 HTTP cache — no Cache-Control parsing, no validators or revalidation. It is an application-level cache for read-mostly APIs, where “a result up to N seconds old is fine” is a decision the caller makes via the CachePolicy. Like the retry wrappers, the caching wrappers preserve the wrapped backend’s execution model (and with it the static types); on a cache hit the wrapped backend — and therefore its hooks — is not involved at all.

Entries are stored in a CacheStore — the bundled MemoryCache is a thread-safe in-process LRU with per-entry expiry; bring your own store (memcached, redis, …) by implementing the two-method protocol. Store calls are synchronous and expected to be fast — except on CachingAsyncBackend, which also accepts an AsyncCacheStore (awaitable get/set) for stores that do network I/O of their own, like redis or memcached.

class action0.client.caching.CacheStore(*args, **kwargs)[source]

Where cached responses live: any object with get/set — the bundled MemoryCache, or your own adapter to memcached, redis and friends. Implementations own the expiry bookkeeping.

get(key)[source]

Look up a cached response.

Parameters:

key (str) – the cache key

Return type:

Response | None

Returns:

the cached response, or None for a miss (including expired entries)

set(key, response, ttl)[source]

Store a response.

Parameters:
  • key (str) – the cache key

  • response (Response) – the response to store

  • ttl (float) – the seconds the entry may be served

Return type:

None

class action0.client.caching.AsyncCacheStore(*args, **kwargs)[source]

The awaitable flavor of CacheStore, for stores that do network I/O of their own — redis, memcached and friends, driven by their asyncio clients. Accepted by CachingAsyncBackend only: the sync and Twisted wrappers have no natural place to await.

get(key)[source]

Look up a cached response.

Parameters:

key (str) – the cache key

Return type:

Awaitable[Response | None]

Returns:

(an awaitable of) the cached response, or None for a miss (including expired entries)

set(key, response, ttl)[source]

Store a response.

Parameters:
  • key (str) – the cache key

  • response (Response) – the response to store

  • ttl (float) – the seconds the entry may be served

Return type:

Awaitable[None]

Returns:

an awaitable completing once stored

class action0.client.caching.MemoryCache(maxsize=128, *, clock=<built-in function monotonic>)[source]

The bundled CacheStore: an in-process, thread-safe LRU with per-entry expiry.

Example:

>>> cache = MemoryCache(maxsize=2)
>>> cache.set("a", Response(200, body="cached"), ttl=60)
>>> cache.get("a")
Response(200 OK)
>>> cache.get("gone") is None
True
Parameters:
  • maxsize (int, default: 128)

  • clock (Callable[[], float], default: <built-in function monotonic>)

  • maxsize – the number of entries kept; the least recently used one is evicted first

  • clock – the monotonic time source (injectable for tests)

Raises:

ValueError – if maxsize is not positive

get(key)[source]

Look up a response, dropping it if expired.

Parameters:

key (str) – the cache key

Return type:

Response | None

Returns:

the cached response, or None for a miss

set(key, response, ttl)[source]

Store a response, evicting the least recently used entries beyond the size limit.

Parameters:
  • key (str) – the cache key

  • response (Response) – the response to store

  • ttl (float) – the seconds the entry may be served; zero or negative stores nothing

Return type:

None

clear()[source]

Drop all entries.

Return type:

None

class action0.client.caching.CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language'))[source]

What to cache, for how long, and under which key — immutable, shared freely between backends.

Parameters:
  • ttl (float, default: 300.0)

  • methods (frozenset[str], default: frozenset({'GET', 'HEAD'}))

  • statuses (frozenset[int], default: frozenset({200}))

  • vary_headers (tuple[str, ...], default: ('Accept', 'Accept-Language'))

ttl: float = 300.0

The seconds a cached response may be served.

methods: frozenset[str] = frozenset({'GET', 'HEAD'})

The methods that are cached at all; everything else always goes to the network.

statuses: frozenset[int] = frozenset({200})

The response statuses worth caching.

vary_headers: tuple[str, ...] = ('Accept', 'Accept-Language')

The request headers that become part of the cache key (so e.g. a German and an English representation of the same URL don’t collide).

key_for(request)[source]

The cache key of a request: method, full URL and the vary_headers values.

Parameters:

request (Request) – the request to key

Return type:

str

Returns:

a digest string

should_lookup(request)[source]

Whether the cache applies to this request at all.

Parameters:

request (Request) – the request about to be sent

Return type:

bool

Returns:

whether to consult (and later fill) the cache

should_store(request, response)[source]

Whether a fresh response should be put into the cache. Responses with streaming bodies are never stored — a BodyProducer may be single-use.

Parameters:
  • request (Request) – the request that was sent

  • response (Response) – the response that arrived

Return type:

bool

Returns:

whether to store it

class action0.client.caching.CachingSyncBackend(inner, policy=CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language')), store=None)[source]

A caching wrapper around a synchronous backend — itself a Backend[Response], so it plugs into the clients like the backend it wraps.

Example:

>>> from action0.client import Client
>>> from action0.client.testing import StubBackend
>>> from action0.req import Request, Response
>>>
>>> inner = StubBackend(Response(200, body="fetched"))
>>> client = Client(CachingSyncBackend(inner))
>>> client.send(Request("https://api.example.com/rates")).body_str()
'fetched'
>>> client.send(Request("https://api.example.com/rates")).body_str()
'fetched'
>>> len(inner.requests)  # the second send never hit the network
1
Parameters:
  • inner (Backend[Response])

  • policy (CachePolicy, default: CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language')))

  • store (CacheStore | None, default: None)

  • inner – the backend that actually sends

  • policy – what to cache and for how long

  • store – where entries live; None creates a MemoryCache

property inner: Backend[Response]

The wrapped backend doing the actual sends.

property store: CacheStore

The cache store (e.g. for clearing it).

send(request)[source]

Serve from the cache when the policy allows and an entry is fresh; otherwise send through the wrapped backend and store a cacheable response.

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

the (possibly cached) response

map(result, fn)[source]

Apply a function to a result of send() — synchronously that is simply fn(result).

Parameters:
Return type:

TypeVar(S)

Returns:

the return value of fn

class action0.client.caching.CachingAsyncBackend(inner, policy=CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language')), store=None)[source]

A caching wrapper around an async backend — itself a Backend[Awaitable[Response]], so it plugs into the clients like the backend it wraps. Takes either store flavor: a plain CacheStore is called synchronously from inside the coroutine (keep it fast — the bundled MemoryCache is), an AsyncCacheStore is awaited, so it may do network I/O of its own (redis, memcached, …).

Parameters:
  • inner (Backend[Awaitable[Response]])

  • policy (CachePolicy, default: CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language')))

  • store (CacheStore | AsyncCacheStore | None, default: None)

  • inner – the backend that actually sends

  • policy – what to cache and for how long

  • store – where entries live, sync or async; None creates a MemoryCache

property inner: Backend[Awaitable[Response]]

The wrapped backend doing the actual sends.

property store: CacheStore | AsyncCacheStore

The cache store (e.g. for clearing it).

async send(request)[source]

Serve from the cache when the policy allows and an entry is fresh; otherwise send through the wrapped backend and store a cacheable response.

Parameters:

request (Request) – the request to send

Return type:

Response

Returns:

(an awaitable of) the (possibly cached) response

map(result, fn)[source]

Apply a function inside an awaitable result of send().

Parameters:
Return type:

Awaitable[TypeVar(S)]

Returns:

an awaitable of the return value of fn

class action0.client.caching.CachingDeferredBackend(inner, policy=CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language')), store=None)[source]

A caching wrapper around a Twisted backend — itself a Backend[Deferred[Response]], so it plugs into the clients like the backend it wraps. Cache hits fire the returned Deferred synchronously.

Parameters:
  • inner (Backend[Deferred[Response]])

  • policy (CachePolicy, default: CachePolicy(ttl=300.0, methods=frozenset({'GET', 'HEAD'}), statuses=frozenset({200}), vary_headers=('Accept', 'Accept-Language')))

  • store (CacheStore | None, default: None)

  • inner – the backend that actually sends

  • policy – what to cache and for how long

  • store – where entries live; None creates a MemoryCache

property inner: Backend[Deferred[Response]]

The wrapped backend doing the actual sends.

property store: CacheStore

The cache store (e.g. for clearing it).

send(request)[source]

Serve from the cache when the policy allows and an entry is fresh; otherwise send through the wrapped backend and store a cacheable response.

Parameters:

request (Request) – the request to send

Return type:

Deferred[Response]

Returns:

a Deferred firing with the (possibly cached) response

map(result, fn)[source]

Apply a function inside a Deferred result of send() — Twisted’s native addCallback.

Parameters:
Return type:

Deferred[TypeVar(S)]

Returns:

a Deferred firing with the return value of fn

Errors

The exception hierarchy shared by all backends and API clients.

exception action0.client.errors.ClientError[source]

The base class of everything raised by action0-client itself.

Catching this catches both transport failures (TransportError) and API-level failures (APIError), but not bugs like TypeError.

exception action0.client.errors.TransportError(message, *, request=None)[source]

The request never produced an HTTP response: DNS failure, connection refused, TLS error, connection lost mid-response, and so on.

Backends translate the exceptions of their HTTP library into this type (or a subclass), so callers only ever need to handle one exception family no matter which backend is plugged in. The original library exception is preserved as __cause__.

Parameters:
  • message (str)

  • request (Request | None, default: None)

  • message – a human-readable description of the failure

  • request – the request that failed, if known

request

The request that failed, None if unknown.

exception action0.client.errors.TimeoutError(message, *, request=None)[source]

The request timed out — a TransportError that is also a TimeoutError (the built-in), so both except TransportError and a plain except TimeoutError catch it.

Parameters:
  • message (str)

  • request (Request | None, default: None)

  • message – a human-readable description of the failure

  • request – the request that failed, if known

exception action0.client.errors.APIError(message, *, request=None, response=None)[source]

An HTTP response arrived but the API interaction failed: an unexpected status code, an empty or malformed body, a payload that doesn’t match the expected schema, …

Raised by the response handling of Operation (and meant to be subclassed for API-specific error types). The offending Response stays available on the exception for inspection.

Parameters:
  • message (str)

  • request (Request | None, default: None)

  • response (Response | None, default: None)

  • message – a human-readable description of the failure

  • request – the request that was sent, if known

  • response – the response that could not be handled, if any

request

The request that was sent, None if unknown.

response

The response that could not be handled, None if there is none.

Built-in backends

The built-in backend implementations, one module per HTTP library so that only the library you actually use needs to be installed (install the matching extra, e.g. pip install "action0-client[httpx]"):

Two backends are stdlib-only and always available:

Nothing is re-exported here on purpose: importing this package must not pull in any of the optional libraries.

The requests backend — a SyncBackend.

Requires the requests extra: pip install "action0-client[requests]".

action0.client.backends.requests.DEFAULT_TIMEOUT = 30.0

The default total number of seconds to wait for connect + read.

class action0.client.backends.requests.RequestsBackend(session=None, *, timeout=30.0, follow_redirects=True, stream=False, hooks=())[source]

A synchronous backend driving a requests.Session.

Example:

from action0.client import Client
from action0.client.backends.requests import RequestsBackend
from action0.req import Request

with RequestsBackend() as backend:
    response = Client(backend).send(Request("https://example.com/"))
    print(response.status)

Notes on fidelity:

  • Streaming request bodies work: a BodyProducer body is handed to requests as a chunk iterator (sent with chunked transfer encoding).

  • Streaming response bodies are opt-in: with stream=True the response body is an IterableBody producing the bytes as they arrive instead of preloaded bytes; the connection is held until the body is consumed (or the producer is garbage-collected).

  • Multiple response header lines with the same name are preserved when urllib3’s raw headers are available; requests itself would merge them.

  • Multiple request header lines with the same name are merged into one comma-separated line, because requests only accepts a mapping. (Cookie is the only field where this could matter in practice.)

Parameters:
  • session (Session | None, default: None)

  • timeout (float | tuple[float, float] | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • stream (bool, default: False)

  • hooks (Iterable[Hook], default: ())

  • session – the session to send through — configure retries, proxies, certificates etc. there; None creates (and owns) a fresh one, closed again by close()

  • timeout – the seconds to wait, either one number for connect and read together or a (connect, read) tuple; None waits forever

  • follow_redirects – whether 3xx responses are followed (transparently, like a browser)

  • stream – whether response bodies arrive as streaming producers instead of preloaded bytes (send then returns at headers arrival)

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize requests’ exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception raised while sending

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

close()[source]

Close the underlying session — but only if this backend created it; a session that was passed in is left to its owner.

Return type:

None

The httpx backends — a SyncBackend and an AsyncBackend sharing one conversion logic, since httpx offers both execution models over one API.

Requires the httpx extra: pip install "action0-client[httpx]".

action0.client.backends.httpx.DEFAULT_TIMEOUT = 30.0

The default number of seconds httpx waits (connect, read, write and pool acquisition each).

class action0.client.backends.httpx.HttpxBackend(client=None, *, timeout=30.0, follow_redirects=True, stream=False, hooks=())[source]

A synchronous backend driving an httpx.Client.

Example:

from action0.client import Client
from action0.client.backends.httpx import HttpxBackend
from action0.req import Request

with HttpxBackend() as backend:
    response = Client(backend).send(Request("https://example.com/"))
    print(response.status)

Streaming response bodies are opt-in: with stream=True the response body is an IterableBody producing the bytes as they arrive instead of preloaded bytes; the connection is held until the body is consumed (or the producer is garbage-collected).

Parameters:
  • client (Client | None, default: None)

  • timeout (float | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • stream (bool, default: False)

  • hooks (Iterable[Hook], default: ())

  • client – the httpx client to send through — configure connection limits, HTTP/2, proxies etc. there; None creates (and owns) a default one, closed again by close(). The timeout and follow_redirects arguments only apply to the created client.

  • timeout – the seconds httpx waits (for connect, read, write and pool acquisition each); None waits forever

  • follow_redirects – whether 3xx responses are followed

  • stream – whether response bodies arrive as streaming producers instead of preloaded bytes (send then returns at headers arrival)

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize httpx’s exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception raised while sending

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

close()[source]

Close the underlying httpx client — but only if this backend created it; a client that was passed in is left to its owner.

Return type:

None

class action0.client.backends.httpx.AsyncHttpxBackend(client=None, *, timeout=30.0, follow_redirects=True, stream=False, hooks=())[source]

An asyncio backend driving an httpx.AsyncClient.

Example:

import asyncio
from action0.client import Client
from action0.client.backends.httpx import AsyncHttpxBackend
from action0.req import Request


async def main() -> None:
    async with AsyncHttpxBackend() as backend:
        response = await Client(backend).send(Request("https://example.com/"))
        print(response.status)


asyncio.run(main())

Streaming response bodies are opt-in: with stream=True the response body is an AsyncIterableBody producing the bytes as they arrive instead of preloaded bytes; the connection is held until the body is consumed (or the producer is garbage-collected).

Parameters:
  • client (AsyncClient | None, default: None)

  • timeout (float | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • stream (bool, default: False)

  • hooks (Iterable[Hook], default: ())

  • client – the httpx client to send through — configure connection limits, HTTP/2, proxies etc. there; None creates (and owns) a default one, closed again by aclose(). The timeout and follow_redirects arguments only apply to the created client.

  • timeout – the seconds httpx waits (for connect, read, write and pool acquisition each); None waits forever

  • follow_redirects – whether 3xx responses are followed

  • stream – whether response bodies arrive as streaming producers instead of preloaded bytes (send then returns at headers arrival)

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize httpx’s exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception raised while sending

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

async aclose()[source]

Close the underlying httpx client — but only if this backend created it; a client that was passed in is left to its owner.

Return type:

None

The aiohttp backend — an AsyncBackend.

Requires the aiohttp extra: pip install "action0-client[aiohttp]".

action0.client.backends.aiohttp.DEFAULT_TIMEOUT = 30.0

The default total number of seconds from sending until the response body finished arriving (aiohttp’s ClientTimeout(total=...)).

class action0.client.backends.aiohttp.AiohttpBackend(session=None, *, timeout=30.0, follow_redirects=True, stream=False, hooks=())[source]

An asyncio backend driving an aiohttp.ClientSession.

Example:

import asyncio
from action0.client import Client
from action0.client.backends.aiohttp import AiohttpBackend
from action0.req import Request


async def main() -> None:
    async with AiohttpBackend() as backend:
        response = await Client(backend).send(Request("https://example.com/"))
        print(response.status)


asyncio.run(main())

A session of its own is created lazily on the first send (an aiohttp.ClientSession must be created inside a running event loop), and closed again by aclose(). Streaming request bodies work: a BodyProducer body is handed to aiohttp as its async chunk iterator. Streaming response bodies are opt-in: with stream=True the response body is an AsyncIterableBody producing the bytes as they arrive instead of preloaded bytes; the connection is held until the body is consumed (or garbage-collected).

Parameters:
  • session (ClientSession | None, default: None)

  • timeout (float | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • stream (bool, default: False)

  • hooks (Iterable[Hook], default: ())

  • session – the session to send through — configure connectors, proxies, cookie jars etc. there; None creates (and owns) one lazily on the first send, closed again by aclose(). The timeout argument only applies to the created session.

  • timeout – the total seconds from sending until the response body finished arriving; None waits forever. NOTE: with stream=True this budget spans the body consumption too — for long-lived streams pass a session with a tailored ClientTimeout (e.g. sock_read instead of total)

  • follow_redirects – whether 3xx responses are followed

  • stream – whether response bodies arrive as streaming producers instead of preloaded bytes (send then returns at headers arrival)

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize aiohttp’s exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception raised while sending

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

async aclose()[source]

Close the underlying session — but only if this backend created it; a session that was passed in is left to its owner.

Return type:

None

The stdlib urllib backend — a SyncBackend without any third-party dependency, so a bare pip install action0-client can already talk HTTP.

For anything demanding (connection pooling, retries, cookies, proxies beyond the environment defaults), prefer the requests or httpx backend.

action0.client.backends.urllib.DEFAULT_TIMEOUT = 30.0

The default number of seconds to wait for the connection and each socket operation.

class action0.client.backends.urllib.UrllibBackend(opener=None, *, timeout=30.0, follow_redirects=True, stream=False, hooks=())[source]

A synchronous backend driving a stdlib urllib.request.OpenerDirector — zero dependencies.

Example:

from action0.client import Client
from action0.client.backends.urllib import UrllibBackend
from action0.req import Request

response = Client(UrllibBackend()).send(Request("https://example.com/"))
print(response.status)

Notes on fidelity:

  • Non-2xx statuses are returned as responses (urllib’s HTTPError is converted back), matching the other backends — status policy belongs to the operation layer.

  • Streaming request bodies work: a BodyProducer body is handed to urllib as a chunk iterator (sent with chunked transfer encoding).

  • Streaming response bodies are opt-in: with stream=True the response body is an IterableBody producing the bytes as they arrive instead of preloaded bytes; the connection is held until the body is consumed (or the producer is garbage-collected).

  • Multiple response header lines with the same name are preserved.

  • Multiple request header lines are merged into one comma-separated line, and urllib normalizes request header casing (X-Api-key style) — semantically equivalent per RFC 9110.

Parameters:
  • opener (OpenerDirector | None, default: None)

  • timeout (float | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • stream (bool, default: False)

  • hooks (Iterable[Hook], default: ())

  • opener – the opener to send through — configure proxy or auth handlers there; None builds a default one. The follow_redirects argument only applies to the built opener.

  • timeout – the seconds to wait for the connection and each socket operation; None waits forever

  • follow_redirects – whether 3xx responses are followed

  • stream – whether response bodies arrive as streaming producers instead of preloaded bytes (send then returns at headers arrival)

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize urllib’s exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception raised while sending

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

close()[source]

Close the opener (and with it any handler-held connections).

Return type:

None

The urllib3 backend — a SyncBackend for projects that use urllib3’s pools directly, without requests on top.

Requires the urllib3 extra: pip install "action0-client[urllib3]".

action0.client.backends.urllib3.DEFAULT_TIMEOUT = 30.0

The default total number of seconds to wait for connect + read.

class action0.client.backends.urllib3.Urllib3Backend(pool=None, *, timeout=30.0, follow_redirects=True, retries=None, stream=False, hooks=())[source]

A synchronous backend driving a urllib3.PoolManager.

Example:

from action0.client import Client
from action0.client.backends.urllib3 import Urllib3Backend
from action0.req import Request

with Urllib3Backend() as backend:
    response = Client(backend).send(Request("https://example.com/"))
    print(response.status)

Notes on fidelity:

  • Non-2xx statuses are returned as responses (urllib3 never raises for them) — status policy belongs to the operation layer.

  • Streaming request bodies work: a BodyProducer body is handed to urllib3 as a chunk iterator.

  • Streaming response bodies are opt-in: with stream=True the response body is an IterableBody producing the bytes as they arrive instead of preloaded bytes; the connection is held until the body is consumed (or the producer is garbage-collected).

  • Multiple response header lines with the same name are preserved (urllib3’s header dict keeps them apart).

  • Multiple request header lines are merged into one comma-separated line, because urllib3 only accepts a mapping.

Parameters:
  • pool (PoolManager | None, default: None)

  • timeout (float | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • retries (Retry | bool | int | None, default: None)

  • stream (bool, default: False)

  • hooks (Iterable[Hook], default: ())

  • pool – the pool manager to send through — configure connection limits, TLS, proxies etc. there; None creates (and owns) a default one, emptied again by close()

  • timeout – the total seconds to wait for connect + read; None waits forever

  • follow_redirects – whether 3xx responses are followed

  • retries – urllib3’s retry policy, passed through per request (a urllib3.util.Retry, a count, or False to raise transport errors immediately); None uses urllib3’s default

  • stream – whether response bodies arrive as streaming producers instead of preloaded bytes (send then returns at headers arrival)

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize urllib3’s exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception raised while sending

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

close()[source]

Empty the pool manager (closing its kept-alive connections) — but only if this backend created it; a pool that was passed in is left to its owner.

Return type:

None

The thread-pool backend — a FuturesBackend (Backend[Future[Response]]), stdlib-only: it wraps any synchronous backend and runs its sends on a ThreadPoolExecutor, so plain sync code gets parallel requests as concurrent.futures.Future results — no async machinery.

class action0.client.backends.futures.ThreadPoolBackend(inner, pool=None, *, max_workers=None)[source]

A backend whose execution model is concurrent.futures.Future: every send runs the wrapped synchronous backend on the thread pool.

Client(ThreadPoolBackend(...)).send(request) is a Future[Response], and APIClient.send returns Future[R] — response parsing (and any instrumentation hooks, which belong on the wrapped backend) runs on the pool threads.

Example:

>>> from action0.client.testing import StubBackend
>>> from action0.req import Request, Response
>>>
>>> with ThreadPoolBackend(StubBackend(Response(200, body="pong"))) as backend:
...     future = backend.send(Request("https://api.example.com/ping"))
...     future.result().body_str()
'pong'

Real-world use — fan out over a shared session, sync code throughout:

from action0.client.backends.requests import RequestsBackend
from action0.client.backends.futures import ThreadPoolBackend

with RequestsBackend() as inner, ThreadPoolBackend(inner) as backend:
    client = APIClient(backend, "https://api.example.com/v1")
    futures = [client.send(GetItem(item_id=item_id)) for item_id in range(100)]
    items = [future.result() for future in futures]  # Future[Item] each
Parameters:
  • inner (Backend[Response])

  • pool (ThreadPoolExecutor | None, default: None)

  • max_workers (int | None, default: None)

  • inner – the synchronous backend that actually sends (put instrumentation hooks there — this wrapper stays out of the way)

  • pool – the executor to run sends on; None creates (and owns) one, shut down again by close()

  • max_workers – the size of the created pool (None is the executor’s default); ignored when a pool is given

property inner: Backend[Response]

The wrapped synchronous backend doing the actual sends.

send(request)[source]

Run the wrapped backend’s send on the pool.

Parameters:

request (Request) – the request to send

Return type:

Future[Response]

Returns:

a Future of the response; transport errors surface when its result is retrieved

map(result, fn)[source]

Apply a function inside a Future result of send(): the returned Future resolves to fn of the original result, and failures (of the send or of fn) propagate. The function runs via a done-callback, so no pool thread is spent waiting.

Parameters:
Return type:

Future[TypeVar(S)]

Returns:

a Future of the return value of fn

close(wait=True)[source]

Shut down the pool — but only if this backend created it; a pool that was passed in is left to its owner. The wrapped backend is never closed here.

Parameters:

wait (bool, default: True) – whether to block until running sends finished

Return type:

None

The Twisted backend — a DeferredBackend driving a twisted.web.client.Agent.

Requires the twisted extra: pip install "action0-client[twisted]" (which includes Twisted’s tls extra, so https:// URLs work).

action0.client.backends.twisted.DEFAULT_TIMEOUT = 30.0

The default total number of seconds from sending until the response body finished arriving.

class action0.client.backends.twisted.TwistedBackend(agent=None, *, reactor=None, timeout=30.0, follow_redirects=True, hooks=())[source]

A Twisted backend: send() returns a Deferred[Response] driven by a twisted.web.client.Agent.

Example:

from twisted.internet import reactor
from action0.client import Client
from action0.client.backends.twisted import TwistedBackend
from action0.req import Request

client = Client(TwistedBackend())
deferred = client.send(Request("https://example.com/"))
deferred.addCallback(lambda response: print(response.status))
deferred.addBoth(lambda _: reactor.stop())
reactor.run()

Streaming request bodies work: a BodyProducer body is streamed through a cooperative task. The response body is always read in full before the Deferred fires.

Parameters:
  • agent (Any, default: None)

  • reactor (Any, default: None)

  • timeout (float | None, default: 30.0)

  • follow_redirects (bool, default: True)

  • hooks (Iterable[Hook], default: ())

  • agent – the IAgent to send through — configure connection pooling, proxies, custom TLS policies etc. there; None creates a plain Agent (wrapped in a RedirectAgent if follow_redirects — the argument only applies to the created agent). Typed loosely because zope interfaces and static checkers don’t mix.

  • reactor – the reactor for the created agent and the timeout clock; None uses the global reactor (imported lazily here, not at module import time)

  • timeout – the total seconds from sending until the response body finished arriving; None waits forever

  • follow_redirects – whether 3xx responses are followed

  • hooks – the instrumentation hooks to run around every send

translate_error(error, request)[source]

Normalize Twisted’s exceptions into the TransportError family.

Parameters:
  • error (Exception) – the exception the send failed with

  • request (Request) – the request that was being sent

Return type:

BaseException

Returns:

the normalized exception (unknown types pass through)

Testing utilities

Test doubles for writing tests against API clients — yours or ones built with this library — without any network I/O.

One stub backend per execution model, all sharing the same behavior:

  • they are constructed with the Response (or responses) to answer with — or callables producing them,

  • they record every request in requests,

  • they run the regular Hook machinery, because they subclass the real backend base classes.

Example:

>>> from action0.req import Request, Response
>>>
>>> backend = StubBackend(Response(200, body="pong"))
>>> backend.send(Request("https://api.example.com/ping")).body_str()
'pong'
>>> backend.requests[0]
Request(GET https://api.example.com/ping)
action0.client.testing.Responder

A callable producing the response for a request — the dynamic alternative to canned Response instances for the stub backends. May raise to exercise error paths.

alias of Callable[[Request], Response]

class action0.client.testing.StubBackend(*responses, hooks=())[source]

A SyncBackend test double: answers with canned responses and records the requests.

Example — scripted responses are handed out in order, the last one repeats:

>>> from action0.req import Request, Response
>>>
>>> backend = StubBackend(Response(200), Response(503))
>>> request = Request("https://api.example.com/health")
>>> [backend.send(request).status for _ in range(3)]
[200, 503, 503]

A callable stands in for dynamic behavior, including raising:

>>> def flaky(request: Request) -> Response:
...     raise ConnectionResetError("nope")
>>> backend = StubBackend(flaky)
>>> backend.send(request)
Traceback (most recent call last):
    ...
ConnectionResetError: nope
Parameters:
  • responses (Response | Callable[[Request], Response])

  • hooks (Iterable[Hook], default: ())

  • responses – the responses (or responder callables) to answer with, in order — the last one repeats; none means “always a plain 200”

  • hooks – the instrumentation hooks to run around every send, like on any real backend

property requests: list[Request]

Every request sent through this backend, in order.

class action0.client.testing.AsyncStubBackend(*responses, hooks=())[source]

An AsyncBackend test double: behaves exactly like StubBackend, but send returns a coroutine like a real async backend.

Example:

>>> import asyncio
>>> from action0.req import Request, Response
>>>
>>> backend = AsyncStubBackend(Response(204))
>>> asyncio.run(backend.send(Request("https://api.example.com/ping"))).status
204
Parameters:
  • responses (Response | Callable[[Request], Response])

  • hooks (Iterable[Hook], default: ())

  • responses – the responses (or responder callables) to answer with, in order — the last one repeats; none means “always a plain 200”

  • hooks – the instrumentation hooks to run around every send, like on any real backend

property requests: list[Request]

Every request sent through this backend, in order.

class action0.client.testing.DeferredStubBackend(*responses, hooks=())[source]

A DeferredBackend test double: behaves exactly like StubBackend, but send returns an already-fired Deferred like a real Twisted backend. The class is importable without twisted installed; calling send requires it.

Example (deferred_result() extracts fired results in tests):

>>> from action0.req import Request, Response
>>>
>>> backend = DeferredStubBackend(Response(204))
>>> deferred = backend.send(Request("https://api.example.com/ping"))
>>> deferred_result(deferred).status
204
Parameters:
  • responses (Response | Callable[[Request], Response])

  • hooks (Iterable[Hook], default: ())

  • responses – the responses (or responder callables) to answer with, in order — the last one repeats; none means “always a plain 200”

  • hooks – the instrumentation hooks to run around every send, like on any real backend

property requests: list[Request]

Every request sent through this backend, in order.

action0.client.testing.deferred_result(deferred)[source]

The result of an already-fired Deferred — the assertion helper for testing Twisted code paths without running a reactor: the stub backend (and error cases of the real one) fire their Deferreds synchronously.

Parameters:

deferred (Deferred[TypeVar(T)]) – the fired Deferred to unwrap

Return type:

TypeVar(T)

Returns:

the value the Deferred fired with

Raises: