Interceptors
An interceptor is async middleware around an operation. It receives an
OperationRequest and call_next, which runs the rest of the chain. It returns
an OperationResponse. The client unwraps that response and returns the usual
typed output to your application.
Use middleware to change parameters, enforce an application policy, time an operation, recover from a failure, or serve a cached response.
For ready-made JSON operation logs, call configure_logging() once.
Logging automatically wraps the application middleware.
import asyncio
import logging
from time import perf_counter
from rboto import Next, OperationRequest, OperationResponse, S3Client, Session, operation_context
log = logging.getLogger(__name__)
async def policy(request: OperationRequest, call_next: Next) -> OperationResponse:
if request.context.service == "s3" and request.context.operation == "DeleteBucket":
raise PermissionError("Bucket deletion is disabled in this application")
return await call_next(request)
async def timing(request: OperationRequest, call_next: Next) -> OperationResponse:
started = perf_counter()
try:
return await call_next(request)
finally:
log.info(
"%s.%s completed in %.3f s; request_id=%s; correlation_id=%s",
request.context.service,
request.context.operation,
perf_counter() - started,
request.metadata.request_id,
request.context.correlation_id,
)
async def main() -> None:
session = Session(region="us-east-1", interceptors=(timing, policy))
client = S3Client(session=session)
with operation_context(correlation_id="request-123"):
await client.list_buckets()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())
How the chain runs
For interceptors=[first, second], execution proceeds as follows:
await call_next(request) forwards the operation. Returning an
OperationResponse without calling it skips the remaining chain and the native
request. Raising an exception also prevents forwarding if call_next has not
run yet. Exceptions propagate through the chain to the caller unless a
middleware explicitly catches them.
Each continuation can be awaited once, on the middleware's task, and only while
that middleware is running. This prevents accidental duplicate sends and
continuations outliving the operation. For a deadline, use asyncio.timeout()
around await call_next(request). Native retries remain under the client's retry
configuration; calling call_next twice is not a retry API.
Change parameters
request.parameters contains the same snake_case names used by the client
method. For Python keyword escapes, use the underlying name, such as from
instead of from_. Values include the original typed mappings and stream
objects; the chain does not deep-copy them.
from rboto import Next, OperationRequest, OperationResponse
async def limit_page_size(request: OperationRequest, call_next: Next) -> OperationResponse:
if request.context.service == "s3" and request.context.operation == "ListObjectsV2":
limit = request.parameters.get("max_keys", 100)
if not isinstance(limit, int):
raise TypeError("max_keys must be an integer")
request.parameters["max_keys"] = min(limit, 100)
return await call_next(request)
You can also forward a replacement mapping:
updated = request.with_parameters({**request.parameters, "max_keys": 100})
return await call_next(updated)
Unknown top-level parameters and missing required parameters are rejected before native dispatch. Values then pass through the usual native conversion and validation. Middleware shared across services must check the service and operation before changing operation-specific parameters.
Return a cached response
The following example caches the immutable output of ListBuckets without
parameters for five seconds. Create a separate cache instance for each client
configuration and credential scope.
from time import monotonic
from rboto import Next, OperationRequest, OperationResponse
class ListBucketsCache:
"""Use one instance per client configuration and credential scope."""
def __init__(self, ttl_seconds: float = 5.0) -> None:
self._ttl = ttl_seconds
self._expires = 0.0
self._output: object | None = None
async def __call__(self, request: OperationRequest, call_next: Next) -> OperationResponse:
if (
request.context.service != "s3"
or request.context.operation != "ListBuckets"
or request.parameters
):
return await call_next(request)
if self._output is not None and monotonic() < self._expires:
return OperationResponse(self._output)
response = await call_next(request)
self._output = response.output
self._expires = monotonic() + self._ttl
return response
Register it with S3Client(interceptors=[ListBucketsCache()]). A cache hit returns
the saved native output without sending another request. In concurrent calls,
this small example may make more than one request while the cache is empty.
A replacement must satisfy the operation's output contract. Regular native outputs remain immutable: return another output of the same generated class rather than a dictionary with similar fields. Event-stream operations retain their documented mapping outputs. Do not cache consumable streams.
After forwarding, you can assign a replacement to response.output and return
that response, or return a new OperationResponse. The same output contract
applies in both cases.
Handle failures
Use ordinary try/except around await call_next(request). Modeled exceptions
retain their classes. A middleware can re-raise, translate an exception, or
return a valid fallback output. Errors in middleware are not swallowed.
Use try/finally for cleanup and instrumentation. Cancellation unwinds the
chain normally, allowing each middleware's finally block to run. Avoid blocking
I/O and unbounded cleanup waits on the event loop.
Configuration and context
A client inherits its Session's middleware unless an explicit sequence replaces it. An empty sequence disables inheritance:
client = S3Client(session=session) # inherit
client = S3Client(session=session, interceptors=[timing]) # replace
client = S3Client(session=session, interceptors=()) # disable
The sequence is copied at construction. Without middleware, operations take the direct native path. Compatibility factories accept the same setting.
request.context is immutable. It provides service, operation,
operation_id, and correlation_id. Each invocation has a unique ID.
operation_context(correlation_id=...) attaches an application ID to calls in
that block, including child tasks created there. Nested blocks restore the
previous ID on exit.
Native metadata
Inspect request.metadata after call_next returns or raises. It is an immutable
snapshot of native dispatch, separate from any later response replacement:
| Field | Meaning |
|---|---|
dispatched |
Whether the native adapter was called; false for cache hits and blocked calls |
region |
Explicit region initially, then resolved region when available |
duration |
Native call elapsed seconds, including initialization and retries |
attempt_count / retry_count |
Native attempts and retries |
status_code / request_id |
Last attempt's status and request ID when available |
error_code / error_type |
Native failure's modeled code and Python exception class name |
attempts |
Up to 32 completed attempts, with number, duration, status, and request ID |
attempts_truncated |
Whether the attempt list omitted additional records |
Before dispatch, attempt_count is zero. After dispatch, None means unavailable,
for example when cancellation prevents metadata delivery. Attempt duration
excludes backoff before the next attempt. Attempt metadata arrives when native
dispatch finishes, rather than as live retry notifications.
A middleware that recovers from an error still sees that native error in the metadata. To time the entire chain, including middleware and cache hits, use an outer middleware as in the first example.
Streaming outputs
call_next may return while a byte stream or event stream is still unread.
Timing the operation does not measure subsequent consumption. Later stream
errors are handled by the code reading the stream.
The chain does not read, clone, or convert responses with to_dict(). Middleware
has access to the actual output, so consuming its stream would also consume it
for the caller. Preserve ownership when replacing outputs or handling streams.