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 rawRequestand returns theResponsein whatever wrapper the backend’s execution model dictates. The wrapper type is derived from the backend (the class is generic overSendResultT_co), not enumerated anywhere — so this works for any execution model, including ones this library has never heard of:Client(RequestsBackend()).send(request)is aResponse,await Client(AsyncHttpxBackend()).send(request)is aResponse,Client(TwistedBackend()).send(request)is aDeferred[Response],with your own
Backend[SomeWrapper[Response]],sendreturns aSomeWrapper[Response].
For talking to a specific API with typed operations, use
APIClientinstead — 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:
- property backend: Backend[SendResultT_co]¶
The backend this client sends through, as the
Backendprotocol. (The client is generic over the backend’s wrapper type, not its concrete class — keep your own reference for backend-specific API likeclose().)
- 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
sendreturns: the response, wrapped according to the backend’s execution model — the plainResponsefor a sync backend, anAwaitable[Response]for an async backend, aDeferred[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)
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 callsfn(result), an async backend awaits first, a Twisted backend usesaddCallback. This is the runtime composition hook that letsAPIClient.sendattach 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 theirmapprecisely 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:
Hookinstrumentation (logging, metrics, tracing, request decoration) around every send, andtranslate_errorfor normalizing library-specific exceptions into theTransportErrorfamily.
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
sendreturns: the response, wrapped according to the backend’s execution model —Response,Awaitable[Response],Deferred[Response], or any custom wrapper.BackendandClientare 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
sendwraps theResponsein — 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
sendreturn 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
resultthat (eventually) holds a valuex, return the same kind of wrapper (eventually) holdingfn(x)— a plain call for sync backends, await-then-call for asyncio,addCallbackfor Twisted.This is the composition hook
APIClient.senduses 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.
- class action0.client.backend.BackendT_co¶
A concrete backend type; what
APIClientis generic over (soclient.backendkeeps the concrete type). Covariant so that e.g. anAPIClient[RequestsBackend]is also anAPIClient[Backend[Response]]— that is what resolves thesendoverloads to the right wrapper.alias of TypeVar(‘BackendT_co’, bound=
Backend[Any], covariant=True)
- action0.client.backend.SyncBackend¶
A synchronous backend:
sendblocks and returns theResponsedirectly. Built-in implementations:RequestsBackend,HttpxBackendand the test doubleStubBackend.
- action0.client.backend.AsyncBackend¶
An asyncio backend:
sendreturns an awaitable of theResponse. Built-in implementations:AsyncHttpxBackendand the test doubleAsyncStubBackend.
- action0.client.backend.DeferredBackend: TypeAlias = 'Backend[Deferred[Response]]'¶
A Twisted backend:
sendreturns aDeferredfiring with theResponse. Built-in implementations:TwistedBackendand the test doubleDeferredStubBackend.
- action0.client.backend.FuturesBackend¶
A thread-pool style backend:
sendreturns aconcurrent.futures.Futureof theResponse. Built-in implementation:ThreadPoolBackend.
- class action0.client.backend.BaseSyncBackend(hooks=())[source]¶
Base class for
SyncBackendimplementations: 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'
mapapplies 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:
- send(request)[source]¶
Send the request: run the
on_requesthooks, perform the I/O via_send(), and run theon_response(or, aftertranslate_error(), theon_error) hooks.- Parameters:
request (
Request) – the request to send- Return type:
- Returns:
the response
- Raises:
BaseException – whatever
translate_errorreturned for the exception raised while sending — aTransportErrorfor the built-in backends
- class action0.client.backend.BaseAsyncBackend(hooks=())[source]¶
Base class for
AsyncBackendimplementations: 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'
mapchains 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:
- async send(request)[source]¶
Send the request: run the
on_requesthooks, perform the I/O via_send(), and run theon_response(or, aftertranslate_error(), theon_error) hooks. All hooks run inside the coroutine, i.e. once it is awaited.- Parameters:
request (
Request) – the request to send- Return type:
- Returns:
(an awaitable of) the response
- Raises:
BaseException – whatever
translate_errorreturned for the exception raised while sending — aTransportErrorfor 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().
- class action0.client.backend.BaseDeferredBackend(hooks=())[source]¶
Base class for
DeferredBackendimplementations: subclasses only implement_send()returning aDeferredof the response and inherit the hook and error-translation plumbing.This class itself is importable without twisted installed (so e.g.
DeferredStubBackendcan 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:
- send(request)[source]¶
Send the request: run the
on_requesthooks, start the I/O via_send(), and chain theon_response(or, aftertranslate_error(), theon_error) hooks onto the Deferred.- Parameters:
request (
Request) – the request to send- Return type:
- Returns:
a Deferred firing with the response, or failing with the translated error — a
TransportErrorfor 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().
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
methodand thepathtemplate — 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 whataction0.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 ofaction0.client.fields, or plainly — thendefault_locationdecides their placement),the class is validated: every
{placeholder}of thepathtemplate must have exactly one matchingpath_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 useJsonOperation, which implements it for JSON APIs). The class attributes fix the constant parts of the endpoint:method— the HTTP method (defaultGET),path— the path template appended to the client’s base URL, with{placeholder}names bound topath_param()fields,accept— anAcceptheader value to request,default_location— where fields without an explicit specifier go (query parameters by default; a JSON-body-heavy API family may wantLocation.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
Noneare 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 overridingcheck()):>>> operation.parse(Response(500, body="boom")) Traceback (most recent call last): ... action0.client.errors.APIError: GetReport: unexpected status 500 Internal Server Error
- path: ClassVar[str] = ''¶
The path template of the endpoint, appended to the client’s base URL.
{placeholder}names are filled from the operation’spath_param()fields. A leading/is optional — the path is always joined to the base URL with exactly one/.
- accept: ClassVar[str | None] = None¶
The
Acceptheader to send, unless one is set explicitly;Nonesends none.JsonOperationsetsapplication/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_FIELDin its common base class.
- as_request(base_url=None)[source]¶
Build the
Requestthis operation describes: the rendered path template appended to the base URL, the query/header/body fields serialized into their places (fields whose value isNoneare omitted), plus aContent-Typefor a JSON or form body and theacceptheader — 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 ofsuper().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";Nonebuilds a relative request (handy for inspecting)- Return type:
- 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 (theParams/Headersclasses coerce them, e.g.Trueto"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.
- 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 isNoneare omitted, like everywhere else), lists/tuples/sets become arrays, scalars andNonepass through.Override to support more value types across an operation family.
- Parameters:
value (
Any) – the field value- Return type:
- 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, thenload()the payload. This is whataction0.client.api.APIClient.send()attaches to the backend’s result viamap.- 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:
- abstractmethod load(response)[source]¶
Turn a checked response into the typed result — the one method a concrete operation must provide (
JsonOperationimplements it for JSON payloads).- Parameters:
response (
Response) – the response, already vetted bycheck()- 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
Operationagainst a JSON endpoint: requests advertiseAccept: application/json, andload()decodes the response body as JSON before handing it toload_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
Acceptheader to send, unless one is set explicitly;Nonesends none.JsonOperationsetsapplication/json.
- load(response)[source]¶
Decode the response body as JSON and delegate to
load_json().- Parameters:
response (
Response) – the response, already vetted bycheck()- 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-urlencodedrequest body.
- BODY = 'body'¶
The entire request body, raw:
bytes,stror aBodyProducer.
- 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:
- 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=valuepair per element; enums are sent as theirvalue;Noneomits the parameter.- Parameters:
name (
str|None, default:None) – the parameter name on the wire;Noneuses the field namedefault (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is requireddefault_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 firstrepr (
bool, default:True) – whether the field shows up in the operation’srepr()
- Return type:
- 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;Noneomits the header.- Parameters:
name (
str|None, default:None) – the header name on the wire;Noneuses the field namedefault (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is requireddefault_factory (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the defaultserialize (
Callable[[Any],Any] |None, default:None) – a custom serializer applied to the value firstrepr (
bool, default:True) – whether the field shows up in the operation’srepr()— passFalsefor credentials
- Return type:
- 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’spathtemplate. The value must serialize to a single scalar and (unlike everywhere else) must not beNone.- Parameters:
default (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is requiredserialize (
Callable[[Any],Any] |None, default:None) – a custom serializer applied to the value firstrepr (
bool, default:True) – whether the field shows up in the operation’srepr()
- Return type:
- 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_fieldfields of an operation together form that object;Nonevalues are omitted. Cannot be combined withjson_body()orbody().- Parameters:
name (
str|None, default:None) – the JSON key on the wire;Noneuses the field namedefault (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is requireddefault_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 firstrepr (
bool, default:True) – whether the field shows up in the operation’srepr()
- Return type:
- 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-urlencodedrequest body — the classic HTML form POST (and the shape of OAuth token endpoints). Allform_fieldfields of an operation together form that body; values serialize like query parameters (a list produces onename=valuepair per element,Noneomits the key). Cannot be combined with the JSON body specifiers orbody().- Parameters:
name (
str|None, default:None) – the form key on the wire;Noneuses the field namedefault (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is requireddefault_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 firstrepr (
bool, default:True) – whether the field shows up in the operation’srepr()— passFalsefor credentials (e.g. OAuth client secrets)
- Return type:
- 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 withjson_field()orbody().- Parameters:
default (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – the field default; without one the field is requireddefault_factory (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the defaultserialize (
Callable[[Any],Any] |None, default:None) – a custom serializer applied to the value firstrepr (
bool, default:True) – whether the field shows up in the operation’srepr()
- Return type:
- 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,stror a streamingBodyProducer(i.e. anaction0.req.body.BodyTypes). At most one per operation; cannot be combined with the JSON body specifiers. Remember to also declare aContent-Type(e.g. via aheader()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 requireddefault_factory (
Any, default:<dataclasses._MISSING_TYPE object at 0x7f7ee9be8050>) – a factory producing the defaultrepr (
bool, default:True) – whether the field shows up in the operation’srepr()
- Return type:
- 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 anOperationinto a request, sends it and parses the response — throughOperation.parse, attached via the backend’smap.The result type follows the operation and the backend: for an
Operation[Item],sendreturnsItemwith 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
sendresult is just typedAny— seesend().)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:
backend (
TypeVar(BackendT_co, bound=Backend[Any], covariant=True))headers (
Iterable[tuple[str,str|int|float|bool|Iterable[str|int|float|bool]]] |Mapping[str,str|int|float|bool|Iterable[str|int|float|bool]] |str|None, default:None)backend – the backend performing the HTTP I/O — any implementation of the
Backendprotocol, whatever its execution modelbase_url – the URL the operations’ paths are appended to, e.g.
"https://api.example.com/v2"(a givenUrlinstance is copied)headers – default header lines added to every request that does not set them itself — the typical place for
Authorizationand friends
- 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
headersare 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
- send(operation)[source]¶
Send an operation: build its request (
Operation.as_requestwith this client’s base URL, thenprepare()), send it through the backend, and parse the response viaOperation.parse— attached with the backend’smap, 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:
- 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-declaresend— 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
awaittime / in the errback instead of being raised here)
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,BaseAsyncBackendorBaseDeferredBackendcalls 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_response(request, response, elapsed)[source]¶
Called after a response arrived, before it is handed to the caller.
- 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 senterror (
BaseException) – the (translated) error about to be raisedelapsed (
float) – the seconds between sending and the failure
- Return type:
- class action0.client.hooks.LoggingHook(logger=None, level=10, error_level=30)[source]¶
A ready-made
Hookthat logs every request, response and error. Requests and responses are logged via theirrepr(), 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:
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>)
- backoff: float = 0.5¶
The seconds to wait before the second attempt; subsequent waits grow by
multiplier.
- 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;
Noneallows 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.
Falsewaits the exact exponential delays.
- respect_retry_after: bool = True¶
Whether a
Retry-Afterresponse header overrides the computed backoff (jitter included) — the server knows best when it is worth coming back. Its value is still capped atmax_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-Afterheader on the response wins over the computed backoff (ifrespect_retry_after); otherwise the delay is exponential, jittered perjitter. Both are capped atmax_backoff.
- should_retry_response(request, response, attempt)[source]¶
Whether a received response should be thrown away and retried.
- should_retry_error(request, error, attempt)[source]¶
Whether a failed send should be retried.
- Parameters:
request (
Request) – the request that was senterror (
BaseException) – the (already translated) error it failed withattempt (
int) – the (1-based) attempt that failed
- Return type:
- 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:
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)
- 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:
- Returns:
the response of the last attempt
- Raises:
BaseException – the error of the last attempt
- 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 withasyncio.sleep()by default; under trio, passsleep=trio.sleep.- Parameters:
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;
Noneusesasyncio.sleep()(passtrio.sleepon trio)
- 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:
- Returns:
(an awaitable of) the response of the last attempt
- Raises:
BaseException – the error of the last attempt, at
awaittime
- 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 viatwisted.internet.task.deferLater()on the given reactor (or the global one).- Parameters:
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;
Noneuses the global reactor (imported lazily on the first send, not at construction)
- 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.
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 bundledMemoryCache, or your own adapter to memcached, redis and friends. Implementations own the expiry bookkeeping.
- 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 byCachingAsyncBackendonly: the sync and Twisted wrappers have no natural place to await.
- 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:
- Raises:
ValueError – if maxsize is not positive
- 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:
- methods: frozenset[str] = frozenset({'GET', 'HEAD'})¶
The methods that are cached at all; everything else always goes to the network.
- 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_headersvalues.
- should_store(request, response)[source]¶
Whether a fresh response should be put into the cache. Responses with streaming bodies are never stored — a
BodyProducermay be single-use.
- 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:
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;
Nonecreates aMemoryCache
- property store: CacheStore¶
The cache store (e.g. for clearing it).
- 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 plainCacheStoreis called synchronously from inside the coroutine (keep it fast — the bundledMemoryCacheis), anAsyncCacheStoreis awaited, so it may do network I/O of its own (redis, memcached, …).- Parameters:
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;
Nonecreates aMemoryCache
- 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.
- 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:
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;
Nonecreates aMemoryCache
- 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.
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 likeTypeError.
- 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:
- request¶
The request that failed,
Noneif unknown.
- exception action0.client.errors.TimeoutError(message, *, request=None)[source]¶
The request timed out — a
TransportErrorthat is also aTimeoutError(the built-in), so bothexcept TransportErrorand a plainexcept TimeoutErrorcatch it.
- 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 offendingResponsestays available on the exception for inspection.- Parameters:
- request¶
The request that was sent,
Noneif unknown.
- response¶
The response that could not be handled,
Noneif 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]"):
action0.client.backends.httpx—HttpxBackend(sync) andAsyncHttpxBackend(asyncio)action0.client.backends.aiohttp—AiohttpBackend(asyncio)action0.client.backends.twisted—TwistedBackend(Deferred)
Two backends are stdlib-only and always available:
action0.client.backends.futures—ThreadPoolBackend(concurrent.futures.Future, wrapping any sync backend)
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
BodyProducerbody is handed to requests as a chunk iterator (sent with chunked transfer encoding).Streaming response bodies are opt-in: with
stream=Truethe response body is anIterableBodyproducing 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. (
Cookieis the only field where this could matter in practice.)
- Parameters:
session (
Session|None, default:None)follow_redirects (
bool, default:True)stream (
bool, default:False)session – the session to send through — configure retries, proxies, certificates etc. there;
Nonecreates (and owns) a fresh one, closed again byclose()timeout – the seconds to wait, either one number for connect and read together or a (connect, read) tuple;
Nonewaits foreverfollow_redirects – whether 3xx responses are followed (transparently, like a browser)
stream – whether response bodies arrive as streaming producers instead of preloaded bytes (
sendthen returns at headers arrival)hooks – the instrumentation hooks to run around every send
- translate_error(error, request)[source]¶
Normalize requests’ exceptions into the
TransportErrorfamily.- Parameters:
- Return type:
- Returns:
the normalized exception (unknown types pass through)
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=Truethe response body is anIterableBodyproducing 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)follow_redirects (
bool, default:True)stream (
bool, default:False)client – the httpx client to send through — configure connection limits, HTTP/2, proxies etc. there;
Nonecreates (and owns) a default one, closed again byclose(). Thetimeoutandfollow_redirectsarguments only apply to the created client.timeout – the seconds httpx waits (for connect, read, write and pool acquisition each);
Nonewaits foreverfollow_redirects – whether 3xx responses are followed
stream – whether response bodies arrive as streaming producers instead of preloaded bytes (
sendthen returns at headers arrival)hooks – the instrumentation hooks to run around every send
- translate_error(error, request)[source]¶
Normalize httpx’s exceptions into the
TransportErrorfamily.- Parameters:
- Return type:
- Returns:
the normalized exception (unknown types pass through)
- 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=Truethe response body is anAsyncIterableBodyproducing 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)follow_redirects (
bool, default:True)stream (
bool, default:False)client – the httpx client to send through — configure connection limits, HTTP/2, proxies etc. there;
Nonecreates (and owns) a default one, closed again byaclose(). Thetimeoutandfollow_redirectsarguments only apply to the created client.timeout – the seconds httpx waits (for connect, read, write and pool acquisition each);
Nonewaits foreverfollow_redirects – whether 3xx responses are followed
stream – whether response bodies arrive as streaming producers instead of preloaded bytes (
sendthen returns at headers arrival)hooks – the instrumentation hooks to run around every send
- translate_error(error, request)[source]¶
Normalize httpx’s exceptions into the
TransportErrorfamily.- Parameters:
- Return type:
- Returns:
the normalized exception (unknown types pass through)
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.ClientSessionmust be created inside a running event loop), and closed again byaclose(). Streaming request bodies work: aBodyProducerbody is handed to aiohttp as its async chunk iterator. Streaming response bodies are opt-in: withstream=Truethe response body is anAsyncIterableBodyproducing 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)follow_redirects (
bool, default:True)stream (
bool, default:False)session – the session to send through — configure connectors, proxies, cookie jars etc. there;
Nonecreates (and owns) one lazily on the first send, closed again byaclose(). Thetimeoutargument only applies to the created session.timeout – the total seconds from sending until the response body finished arriving;
Nonewaits forever. NOTE: withstream=Truethis budget spans the body consumption too — for long-lived streams pass a session with a tailoredClientTimeout(e.g.sock_readinstead oftotal)follow_redirects – whether 3xx responses are followed
stream – whether response bodies arrive as streaming producers instead of preloaded bytes (
sendthen returns at headers arrival)hooks – the instrumentation hooks to run around every send
- translate_error(error, request)[source]¶
Normalize aiohttp’s exceptions into the
TransportErrorfamily.- Parameters:
- Return type:
- Returns:
the normalized exception (unknown types pass through)
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
HTTPErroris converted back), matching the other backends — status policy belongs to the operation layer.Streaming request bodies work: a
BodyProducerbody is handed to urllib as a chunk iterator (sent with chunked transfer encoding).Streaming response bodies are opt-in: with
stream=Truethe response body is anIterableBodyproducing 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-keystyle) — semantically equivalent per RFC 9110.
- Parameters:
opener (
OpenerDirector|None, default:None)follow_redirects (
bool, default:True)stream (
bool, default:False)opener – the opener to send through — configure proxy or auth handlers there;
Nonebuilds a default one. Thefollow_redirectsargument only applies to the built opener.timeout – the seconds to wait for the connection and each socket operation;
Nonewaits foreverfollow_redirects – whether 3xx responses are followed
stream – whether response bodies arrive as streaming producers instead of preloaded bytes (
sendthen returns at headers arrival)hooks – the instrumentation hooks to run around every send
- translate_error(error, request)[source]¶
Normalize urllib’s exceptions into the
TransportErrorfamily.- Parameters:
- Return type:
- Returns:
the normalized exception (unknown types pass through)
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
BodyProducerbody is handed to urllib3 as a chunk iterator.Streaming response bodies are opt-in: with
stream=Truethe response body is anIterableBodyproducing 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)follow_redirects (
bool, default:True)stream (
bool, default:False)pool – the pool manager to send through — configure connection limits, TLS, proxies etc. there;
Nonecreates (and owns) a default one, emptied again byclose()timeout – the total seconds to wait for connect + read;
Nonewaits foreverfollow_redirects – whether 3xx responses are followed
retries – urllib3’s retry policy, passed through per request (a
urllib3.util.Retry, a count, orFalseto raise transport errors immediately);Noneuses urllib3’s defaultstream – whether response bodies arrive as streaming producers instead of preloaded bytes (
sendthen returns at headers arrival)hooks – the instrumentation hooks to run around every send
- translate_error(error, request)[source]¶
Normalize urllib3’s exceptions into the
TransportErrorfamily.- Parameters:
- Return type:
- Returns:
the normalized exception (unknown types pass through)
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 aFuture[Response], andAPIClient.sendreturnsFuture[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:
pool (
ThreadPoolExecutor|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;
Nonecreates (and owns) one, shut down again byclose()max_workers – the size of the created pool (
Noneis the executor’s default); ignored when apoolis given
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 aDeferred[Response]driven by atwisted.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
BodyProducerbody 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)follow_redirects (
bool, default:True)agent – the
IAgentto send through — configure connection pooling, proxies, custom TLS policies etc. there;Nonecreates a plainAgent(wrapped in aRedirectAgentiffollow_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;
Noneuses the global reactor (imported lazily here, not at module import time)timeout – the total seconds from sending until the response body finished arriving;
Nonewaits foreverfollow_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
TransportErrorfamily.- Parameters:
- Return type:
- 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
Hookmachinery, 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
Responseinstances for the stub backends. May raise to exercise error paths.
- class action0.client.testing.StubBackend(*responses, hooks=())[source]¶
A
SyncBackendtest 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 – 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
- class action0.client.testing.AsyncStubBackend(*responses, hooks=())[source]¶
An
AsyncBackendtest double: behaves exactly likeStubBackend, butsendreturns 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 – 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
- class action0.client.testing.DeferredStubBackend(*responses, hooks=())[source]¶
A
DeferredBackendtest double: behaves exactly likeStubBackend, butsendreturns an already-firedDeferredlike a real Twisted backend. The class is importable without twisted installed; callingsendrequires 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 – 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
- 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:
BaseException – the exception the Deferred failed with, if it failed
AssertionError – if the Deferred has not fired yet