Documentation
¶
Overview ¶
Package http adapts idempotency to HTTP, on both sides of the wire.
The server half is a routing.Middleware keyed off an Idempotency-Key header. The client half is an http.RoundTripper that sends the key. They live together so the header name is one constant rather than two that can drift.
Server ¶
manager, err := idempotencyhttp.NewManager(store, locker)
if err != nil {
return err
}
mw, err := idempotencyhttp.NewMiddleware(manager,
idempotencyhttp.WithPrincipalExtractor(principalFromRequest),
)
if err != nil {
return err
}
routing.Post(router, "/charges", createCharge, routing.WithMiddleware(mw))
Install it per route. It caps request bodies in order to fingerprint them, and a global Router.Use would impose that cap on upload routes that never asked for it. Requests without the header pass through with their body untouched, so opting in is the only way to be affected.
Use NewManager rather than idempotency.NewManager directly. The core package records every result, which is right for something that knows nothing about status codes and wrong here: a 500 recorded once would replay for the whole TTL. NewManager applies Recordable, which draws the line at 5xx.
Client ¶
c := httpclient.NewHTTPClient(cfg)
c.Transport = idempotencyhttp.NewTransport(c.Transport)
ctx, _ := idempotency.WithNewKey(ctx) // once, OUTSIDE the retry loop
err := policy.Do(ctx, func(ctx context.Context) error {
res, err := c.Do(req.Clone(ctx))
...
})
The transport sends the key the context carries and never invents one — see NewTransport for why that restraint is the whole point.
Two things it deliberately does not do. It does not buffer request bodies, so a retried request needs a replayable one: rebuild it per attempt, or rely on http.Request.GetBody, which http.NewRequest fills in for the common body types. And it does not hoist the key out of the retry loop for you; only the caller knows where a logical operation begins.
What a duplicate gets back ¶
A completed record replays its status, its allowlisted headers, and its body, marked with Idempotent-Replayed. A request that arrives while the first is still running gets 409 with Retry-After. A key presented with a different request gets 422.
Replay is close but not byte-exact, in two documented ways.
Headers are an allowlist, defaulting to Content-Type alone. This middleware runs inside the standard stack, so CORS, request IDs, and trace headers are reapplied fresh by outer middleware on the replay; replaying the stored copies would stamp the replay with the original request's trace. A stored Set-Cookie would be worse, re-setting a session that has since moved on.
Bodies over WithMaxResponseBytes are dropped. The status still replays, so the effect does not repeat, and the response carries Idempotency-Body-Omitted so the client is not misled about the empty body. Watch that header's counter and raise the cap if it fires.
The fingerprint ¶
Method, path, sorted query, principal, and body hash, so one key cannot answer two different requests. The principal comes from WithPrincipalExtractor: there is no platform-wide notion of a caller to read from, and without one two users sending the same key for the same request would share a record.
The body is hashed as raw bytes. A client that re-serializes its JSON between attempts changes the fingerprint and is told it reused its key — strict, but the safe direction to err in. WithFingerprint is there for callers who would rather canonicalize.
Recording ¶
Anything short of 5xx is recorded. A 4xx is a stable answer, so replaying it is correct and cheaper than running the handler again. A 5xx is not: it usually means the work never landed, so the claim is released and the retry runs.
The cost is the one hole in the guarantee — a handler that has its effect and then fails will repeat that effect on retry. Recording 5xx would trade that rare case for a common and worse one, where a transient failure is pinned for the whole TTL and the client can never succeed.
The response reaches the client as the handler writes it, before the record is stored. That ordering is deliberate: returning the handler's real answer is what stops the client retrying at all. When the record then fails to store, idempotency_record_failures fires and the next retry runs the handler again.
Example ¶
Example shows the client and server halves together: one key, two requests, one execution.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
cachememory "github.com/primandproper/platform-go/v9/cache/memory"
"github.com/primandproper/platform-go/v9/distributedlock"
dlmemory "github.com/primandproper/platform-go/v9/distributedlock/memory"
"github.com/primandproper/platform-go/v9/idempotency"
idempotencyhttp "github.com/primandproper/platform-go/v9/idempotency/http"
)
func newMiddleware() (func(http.Handler) http.Handler, error) {
store, err := cachememory.NewInMemoryCache[idempotency.Record[idempotencyhttp.Response]](0)
if err != nil {
return nil, err
}
locker, err := dlmemory.NewLocker()
if err != nil {
return nil, err
}
scoped, err := distributedlock.NewScopedLocker(locker)
if err != nil {
return nil, err
}
// NewManager rather than idempotency.NewManager: it applies the HTTP rule
// that a 5xx is not recorded.
manager, err := idempotencyhttp.NewManager(store, scoped)
if err != nil {
return nil, err
}
return idempotencyhttp.NewMiddleware(manager)
}
// Example shows the client and server halves together: one key, two requests,
// one execution.
func main() {
mw, err := newMiddleware()
if err != nil {
panic(err)
}
charges := 0
srv := httptest.NewServer(mw(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) {
charges++
res.Header().Set("Content-Type", "application/json")
res.WriteHeader(http.StatusCreated)
_, _ = res.Write([]byte(`{"id":"ch_1"}`))
})))
defer srv.Close()
client := srv.Client()
client.Transport = idempotencyhttp.NewTransport(client.Transport)
// Minted once, outside the loop, so both attempts carry the same key.
ctx, _ := idempotency.WithNewKey(context.Background())
for range 2 {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/charges", strings.NewReader(`{"amount":10}`))
if reqErr != nil {
panic(reqErr)
}
res, doErr := client.Do(req)
if doErr != nil {
panic(doErr)
}
fmt.Println(res.StatusCode, "replayed:", res.Header.Get(idempotencyhttp.ReplayHeader) == "true")
_ = res.Body.Close()
}
fmt.Println("charges:", charges)
}
Output: 201 replayed: false 201 replayed: true charges: 1
Index ¶
- Constants
- Variables
- func NewManager(store Store, locker distributedlock.ScopedLocker, opts ...idempotency.Option) (*idempotency.Manager[Response], error)
- func NewMiddleware(manager *idempotency.Manager[Response], opts ...Option) (routing.Middleware, error)
- func NewTransport(base http.RoundTripper, opts ...TransportOption) http.RoundTripper
- func Recordable(res *Response) bool
- type Option
- func WithFingerprint(fn func(req *http.Request, body []byte) (idempotency.Fingerprint, error)) Option
- func WithHeaderName(name string) Option
- func WithLogger(logger logging.Logger) Option
- func WithMaxRequestBodyBytes(limit int64) Option
- func WithMaxResponseBytes(limit int) Option
- func WithMethods(methods ...string) Option
- func WithMetricsProvider(metricsProvider metrics.Provider) Option
- func WithPrincipalExtractor(extract func(*http.Request) (string, error)) Option
- func WithReplayHeaderName(name string) Option
- func WithReplayedHeaders(names ...string) Option
- func WithRetryAfter(after time.Duration) Option
- func WithTracerProvider(tracerProvider tracing.TracerProvider) Option
- type Response
- type Store
- type TransportOption
Examples ¶
Constants ¶
const ( // HeaderName is the request header carrying the key. Both halves of this // package read it from here, so the client and the server cannot drift. HeaderName = "Idempotency-Key" // ReplayHeader marks a response that was replayed rather than produced. ReplayHeader = "Idempotent-Replayed" // BodyOmittedHeader marks a replay whose body was dropped for exceeding // the recorded-response cap. BodyOmittedHeader = "Idempotency-Body-Omitted" // DefaultMaxRequestBodyBytes bounds how much of a request body is read to // fingerprint it. DefaultMaxRequestBodyBytes = 1 << 20 // 1 MiB // DefaultMaxResponseBytes bounds how much of a response body is recorded. // Beyond it the status is still recorded, so the effect does not repeat, // but the body is dropped. DefaultMaxResponseBytes = 256 << 10 // 256 KiB // DefaultRetryAfter is the Retry-After sent with a 409, giving a client // some idea of when the in-flight work might have finished. DefaultRetryAfter = time.Second )
Variables ¶
var ErrNilManager = platformerrors.New("nil idempotency manager")
ErrNilManager indicates NewMiddleware was called without a manager.
Functions ¶
func NewManager ¶
func NewManager( store Store, locker distributedlock.ScopedLocker, opts ...idempotency.Option, ) (*idempotency.Manager[Response], error)
NewManager builds a manager for HTTP responses with Recordable already applied.
It exists so the rule above cannot be forgotten. idempotency.NewManager records everything by default, which is right for a package that knows nothing about status codes and wrong for HTTP — and the failure is silent: a 500 recorded once replays for the whole TTL.
Options are appended after the default, so a caller passing their own idempotency.WithRecordable still wins.
func NewMiddleware ¶
func NewMiddleware(manager *idempotency.Manager[Response], opts ...Option) (routing.Middleware, error)
NewMiddleware builds middleware that runs a handler at most once per Idempotency-Key.
Requests without the header pass through completely untouched — the body is not even read — so installing this can only affect clients that opted in.
Prefer installing it per route with routing.WithMiddleware rather than globally with Router.Use. It caps request bodies in order to fingerprint them, and a global install would impose that cap on upload routes that never asked for it.
func NewTransport ¶
func NewTransport(base http.RoundTripper, opts ...TransportOption) http.RoundTripper
NewTransport wraps base so that requests whose context carries an idempotency key send it.
It composes the same way the platform's tracing transport does, so no change to httpclient is needed:
c := httpclient.NewHTTPClient(cfg)
c.Transport = idempotencyhttp.NewTransport(c.Transport)
ctx, _ := idempotency.WithNewKey(ctx) // once, OUTSIDE the retry loop
err := policy.Do(ctx, func(ctx context.Context) error {
res, err := c.Do(req.Clone(ctx)) // every attempt sends the same key
...
})
A nil base falls back to http.DefaultTransport.
It never invents a key ¶
If the context carries no key and the header is not already set, this does nothing at all. That is the most important property here, and it is not timidity:
A RoundTripper cannot tell a retry from a second, deliberate request — they are byte-identical. Minting a key per call would produce a different key on every attempt, which offers no protection while looking like it does. Deriving one from the request's content would fail the other way, by deciding two intentional identical charges are the same one and silently dropping the second.
Only the caller knows where a logical operation begins, which is what idempotency.WithNewKey expresses. An already-set header always wins, so a caller managing keys itself is never overridden.
func Recordable ¶
Recordable is the HTTP rule for which responses are worth recording.
A 4xx is a stable answer: the same request will be rejected the same way, so replaying it is both correct and cheaper than running the handler again. A 5xx is not. It usually means the work never landed, and pinning it for the whole TTL would leave a client unable to ever succeed with that key — so the claim is released and the next attempt runs the handler.
The cost of that choice is the one hole in the guarantee: a handler that has its effect and then fails will repeat the effect on retry. Recording 5xx instead trades that rare case for a common and worse one.
Types ¶
type Option ¶
type Option func(*config)
Option configures the middleware.
func WithFingerprint ¶
func WithFingerprint(fn func(req *http.Request, body []byte) (idempotency.Fingerprint, error)) Option
WithFingerprint replaces how a request is fingerprinted.
Use it when the default is too strict — most usefully to canonicalize a JSON body, since the default hashes raw bytes and a client that re-serializes before retrying would otherwise be reported as reusing its key.
func WithHeaderName ¶
WithHeaderName overrides the request header carrying the key.
func WithMaxRequestBodyBytes ¶
WithMaxRequestBodyBytes bounds how much of a request body is read. A request over the limit is answered with 413 rather than fingerprinted on a prefix, which would let two different requests share a fingerprint.
func WithMaxResponseBytes ¶
WithMaxResponseBytes bounds how much of a response body is recorded. Beyond it the status is still recorded and the body dropped, so the guarantee holds and only the convenience is lost.
func WithMethods ¶
WithMethods overrides which methods participate.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider.
func WithPrincipalExtractor ¶
WithPrincipalExtractor supplies the caller identity to fold into the fingerprint.
There is no platform-wide notion of a principal to read from, so this is how one gets in. Supplying it matters for multi-tenant APIs: without it, two users who send the same key for the same request would share a record, and the second would be handed the first's response.
func WithReplayHeaderName ¶
WithReplayHeaderName overrides the response header marking a replay. An empty name suppresses it.
func WithReplayedHeaders ¶
WithReplayedHeaders overrides the headers a replay reproduces. Keep it short — see defaultReplayedHeaders for what goes wrong otherwise.
func WithRetryAfter ¶
WithRetryAfter sets the Retry-After sent with a 409.
func WithTracerProvider ¶
func WithTracerProvider(tracerProvider tracing.TracerProvider) Option
WithTracerProvider attaches a tracer provider.
type Response ¶
type Response struct {
// Header holds the headers worth replaying, already filtered to the
// middleware's allowlist. It is not the handler's whole header map — see
// WithReplayedHeaders for why replaying everything is wrong.
Header http.Header
// Body is the recorded body, empty when Truncated.
Body []byte
// StatusCode is the status the handler produced, normalized so a handler
// that wrote nothing records 200 rather than 0.
StatusCode int
// Truncated reports that the response outgrew the configured cap and its
// body was dropped. The status is still replayable, which is what keeps
// the effect from repeating.
Truncated bool
}
Response is the recorded half of an HTTP exchange: enough to answer a duplicate request without running the handler again.
Its fields are exported and its types are gob-friendly, because the record store serializes it.
type Store ¶
type Store = cache.Cache[idempotency.Record[Response]]
Store is the record store an HTTP manager reads and writes. It is spelled out because the type is a mouthful at every call site.
type TransportOption ¶
type TransportOption func(*transport)
TransportOption configures the client transport.
func WithTransportHeaderName ¶
func WithTransportHeaderName(name string) TransportOption
WithTransportHeaderName overrides the header the transport stamps.
func WithTransportMethods ¶
func WithTransportMethods(methods ...string) TransportOption
WithTransportMethods overrides which methods the transport stamps.