How-to › Ship an SDK

How to wrap an SDK transport in a middleware chain#

Compose retry, caching, authentication and tracing as layers around one transport, so each concern is written once and the order is declared rather than implied.

Audience
API producer
Level
intermediate
Topic
Compose runtime features in an SDK
Languages
Python
Verified

Retry logic sits in three operations, the auth header is set in five, and the caching somebody added last quarter lives in a decorator on two of them. A new operation ships without the retry. Nobody notices until a vendor spends an afternoon refusing requests and one code path gives up on the first attempt.

What you get

You will end up with each concern written once, as a layer around a single transport. Adding an operation gets the whole stack for free, and the order of the stack is one readable line. This is for you if the same cross-cutting logic is copied across your client.

Short answer

Write each concern as a function that takes the next call and returns a call, then compose them outermost first so the declaration order is the order a request travels. A layer may change the request, inspect the response, call the next layer more than once, or answer without calling it at all. Nesting order decides what a retry counts and what a cache hit skips.

You will need

Python 3.11 or later, and an SDK or client whose transport you can wrap. The shape is the same one WSGI uses, and the same one the Guzzle handler stack uses in PHP.1 The idea transfers to whichever language your client is written in, and so does the ordering argument below.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A chain of wrapping functionsYou own the transport and want the order visible where the client is builtCode you maintain, and a chain that can be assembled wronglyThe client library already exposes a stack of its own
Guzzle handler stack middlewareA PHP client built on Guzzle, where the stack is already thereNamed positions and a push API, which is more machinery than a listThe client is not built on Guzzle
requests transport adaptersPer-host behavior such as pooling, TLS settings or a mounted retryOne adapter per host prefix, and a hook model that is not a chainThe behavior belongs to every host the client calls
urllib3 retriesRetry alone, configured once, with backoff and status lists includedOnly retry, so every other concern still needs somewhere to liveYou need caching, tracing or auth in the same place

The difference is whether the ordering is yours. A library stack gives you tested behavior and decides for you where retry sits relative to everything else.2 A chain you compose makes that a line in your code, and it makes the wrong order equally easy to write. Take the library stack when it covers the concerns you have, and compose when it does not.

Compose outermost first

One function builds the chain, and the order reads the way a request travels.

def chain(transport, *layers):
    """Compose layers around a transport. The first layer is the outermost."""
    call = transport
    for layer in reversed(layers):
        call = layer(call)
    return call

Every layer has the same shape: take the next call, return a call. That uniformity is what lets you reorder the stack without editing a layer, and it is why a layer can do things a hook cannot. A retry layer calls the next one several times. A cache layer sometimes calls it not at all. A hook that fires before a request and another that fires after cannot express either of those, which is why a hook API turns into a chain as soon as retry arrives.

Put the cache outside the retry

Nesting is not a style question, it changes what happens.

call = chain(transport, trace(log), cache(store, log), retry(3, log=log), auth("sk_live_1"))

With the cache outside, a hit skips the retry layer entirely and costs nothing. Flip the two, and every cache hit still walks through the retry machinery, and a retried request writes its intermediate failures past a cache that is now inside the loop.

Auth sits innermost, next to the transport. A retried attempt then gets a header applied again, which matters the moment that header is a signature over the request or a token that can expire between attempts.

Both layers ask the method before they act. A 503 can arrive after the server already processed the request, so a retried POST is how one charge becomes two. A cache keyed on method and path is no safer: it would hand the first response to a second POST carrying a different body, and that second write would never leave the client. Retry the idempotent methods, cache the safe ones, and let a write through untouched.3 Add POST to the retry list only for an API that takes an idempotency key.

Check it worked

Run the chain against a transport that fails twice, then succeeds, then gets asked the same question again.

python3 demo.py
order: trace cache retry auth transport
  -> /meters
  retry after 503
  retry after 503
  <- 200
  -> /meters
  hit /meters
  <- 200
  -> /meters/mtr_8f2
  <- 200
first : 200 {'path': '/meters', 'auth': 'Bearer sk_live_1'}
second: 200 served from the cache
third : 200 {'path': '/meters/mtr_8f2', 'auth': 'Bearer sk_live_1'}

The trace layer logs one arrow pair per call from the caller’s point of view, and the two retries happen inside it. That is the trace you want: a caller made one request and it took three attempts, rather than three requests that each look independent.

The third call shows the layers doing nothing interesting, which is the common case. One path through a stack of five layers, one transport call, the auth header applied. A chain earns its place by being unremarkable on the requests that work.

python3 test_middleware.py
ok declaration_order_is_request_order
ok a_layer_can_answer_without_calling_the_transport
ok retry_sits_inside_cache_so_a_hit_costs_no_attempts
ok a_failure_is_not_cached
ok every_layer_sees_the_same_request_object
ok a_write_is_not_retried
ok a_write_is_not_served_from_the_cache
7 cases, 0 failures

When it goes wrong

Retries are invisible in your traces. The trace layer sits inside the retry layer, so each attempt opens its own span with no parent. Move tracing outward, and record the attempt count on the span.

A signed request fails on the second attempt. The signature was computed once, outside the retry, and the timestamp inside it has aged. Sign innermost, per attempt, and make the signing layer read the request as it stands rather than a copy taken earlier.

A cache serves an error. The cache layer stores whatever the next layer returned. Store only the statuses you meant to store, and assert that in a test.

The chain works in tests and not in production. The layers are built per request rather than once, so the cache and any circuit breaker start empty every time. Build the chain when the client is built, and hand the same instance to every operation.

When not to do this

Do not make a layer that knows about one operation. The value of the chain is that every call gets the same treatment, and a layer with a path check in it is a special case pretending to be infrastructure. Put that logic in the operation, where the next reader will look for it.

Do not stack layers you cannot explain the order of. Six layers whose nesting nobody can justify produce behavior nobody predicted, and the failure appears under load. Write the reason for the order in a comment beside the chain, because the chain is one line and the reasoning is not.

Do not use the chain for business rules. Anything that changes what an operation means belongs where a reader of that operation will find it. A layer that rewrites a field for one customer is the start of a client nobody can debug.

Last verified

Verified 2026-09-14 against Python 3.11.15. Both output blocks are what the preceding command printed.

Footnotes

  1. PEP 3333 describes itself as an updated version of PEP 333, modified slightly to improve usability under Python 3. Its section on the pattern is headed Middleware: Components that Play Both Sides. PEP 333 is dated December 2003, and Phillip J. Eby is the author of both. A function that takes the next call and returns a call has been a Python convention for twenty-two years, with one revision. The revision was for the language rather than the idea. ↩︎ Back to text

  2. Guzzle’s handler stack is the chain with a management layer. Its documentation says middleware functions return a function that accepts the next handler to invoke, which is this page’s shape exactly. It then offers push, unshift, before, after and remove for placing one, by position or by the name of a neighbor, and create installs four handlers of its own before you add any. The order is decided for you, and then it is negotiable. ↩︎ Back to text

  3. urllib3 reached the same list. Its Retry retries DELETE, GET, HEAD, OPTIONS, PUT and TRACE by default, six methods with POST the notable absentee, and the parameter documentation says the set is the methods considered idempotent. The class signature defaults total to 10, and the user guide says urllib3 will retry requests 3 times by default, which leaves a reader who wanted one number holding two. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.