Skip to content

Why the defaults are what they are

Several of transit's defaults are more conservative than the obvious choice, and a few will look like they are getting in your way. Each one is a decision about which mistake is worse to make silently. This page states the reasoning for the ones that most often prompt the question "why won't it just do the thing I asked?".

The settings themselves are in the reference; this page is only about why they default the way they do.

Why isn't my POST being retried?

Because a retry is a resend, and a resend of a non-idempotent request can double an effect the origin has already applied. A POST that creates an order, charges a card or enqueues a job may well have succeeded before the response was lost; retrying it creates a second order.

So by default the retry transport resends only the RFC 9110 idempotent methods — GET, HEAD, OPTIONS, PUT, DELETE — when it receives a retryable status code. Earlier releases of this module retried any method, which meant a 503 from a load balancer could silently double-submit.

The distinction that makes this workable is when the failure happened. A failure that provably occurred before the request reached the origin — a dial that never connected, a refused connection — proves no work was done, so it is retried for every method, including POST. Only failures that might have been observed by the origin are gated.

If your requests carry idempotency keys, the gate is wrong for you and you should say so explicitly with RetryableMethods or WithRetryAllMethods(). That is an opt-in rather than an opt-out because the safe direction has to be the quiet one: a caller who has thought about idempotency will find the option, and a caller who has not will not lose money to a default.

Why is my client IP 10.0.0.7 instead of the real user's?

Because X-Forwarded-For and X-Real-IP are just request headers, and any client talking to your server directly can set them to anything. A logging middleware that trusted them would let anyone choose the IP that appears in your audit trail — including someone else's.

So by default transit logs the peer of the TCP connection, which cannot be forged. Behind a load balancer, that peer is the load balancer, and the header holds the value you actually want. WithTrustedProxy() switches to it.

The reason this is an opt-in rather than a heuristic is that the middleware cannot tell the difference. "Is there a trusted proxy in front of me?" is a deployment fact, not something observable from a request. Any automatic guess is wrong in one direction or the other, and the wrong direction here is silently accepting forged data.

The same reasoning produces a stricter rule for the rate limiter. ClientIPKey never consults those headers, even when logging has been told to trust them, because a spoofable limiter key is worse than a spoofable log line: an attacker can escape their own bucket by rotating a header, and churn the bounded key store while doing it. Behind a trusted proxy, supply a KeyFunc that reads the header your proxy sets — deliberately, at the point where you know what that header is.

Why is my bearer token pinned to one host?

Because credential middleware is an http.RoundTripper, and a RoundTripper runs again on every redirect hop.

net/http does strip Authorization when a redirect crosses to another host, which sounds like it covers this. It does not: that stripping only governs headers set on the initial request. A RoundTripper sets the header freshly on each hop, after the redirect decision has been made. Without a pin, a downstream that answers 302 Location: https://attacker.example/ receives your token.

So WithBearerToken and WithBasicAuth attach the credential only when the request host matches the pinned host, log a WARN naming both hosts when it does not, and send the request without the credential. The failure mode is a 401 and a log line, which is recoverable; the alternative failure mode is a leaked credential, which is not.

Supplying the host explicitly matters. Called without one, the middleware pins to the first host it happens to see — which under concurrency depends on which goroutine got there first. That form still exists for compatibility and is deprecated.

Why is a header I asked for logged as [REDACTED]?

Because WithHeaderFields is a convenience, not an override. Naming a header there says "include this in the record"; it does not say "I have checked that this header never carries a secret".

Values are checked against the shared catalogue in go/redact and replaced when they match. The catalogue is two rules: an exact list of well-known credential headers, and a fuzzy pattern over the name — any whole word auth, token, key, secret, bearer, password or credential. That second rule is the one that catches X-Tenant-Key and X-Custom-Auth, headers no fixed list would have contained.

The pattern is deliberately wider than a strict allowlist would be, because it is answering "is an operator likely to have put a secret in here?" rather than "is this on my list?". It over-redacts, and over-redacting costs you a log field, while under-redacting costs you a credential in a log aggregator that a dozen people can search.

There is no way to disable it. A per-call opt-out would be used, and the first time it was used wrongly nobody would notice.

Why does the server limiter reject instead of queueing?

Because queueing inbound work is how a server dies under load. Each waiting request holds a goroutine, a connection and its buffers; a flood that arrives faster than the limiter drains it does not slow down, it accumulates until the process runs out of memory. The limiter would have converted a fast, cheap rejection into a slow, expensive crash.

So server-side admission is non-blocking: over-limit requests get an immediate 429 (or ResourceExhausted on gRPC) and the resources go straight back.

The outbound limiter does the opposite and blocks, because the situation is inverted. Throttling your own client means making your own goroutine wait, which is the behaviour you asked for — you would rather the call be slow than be refused. The party being made to wait is you, and you consented.

Why doesn't a 429 open the circuit breaker?

Because 429 Too Many Requests — and its gRPC analogue ResourceExhausted — is not a sign that the downstream is unhealthy. It is a sign that the downstream is healthy enough to enforce its own limits, and that you are over them.

A breaker that counted those would take a downstream's own protection and turn it into a client-side outage: the server successfully sheds a little load, its callers' breakers all open, and now nothing gets through at all. The correct response to a 429 is to back off and try again, which is retry's job.

The same reasoning is why the HTTP breaker counts only 5xx, and the gRPC breaker counts only Unavailable and DeadlineExceeded. A 404 or an InvalidArgument means your request was wrong, not that the server is down; opening a breaker on those would let one buggy call path cut off every other caller sharing the transport.

Both classifications are replaceable through IsFailure when your downstream signals unhealthiness some other way.

Why does a bad config value get clamped instead of rejected?

Because the middleware is constructed on a service's startup path, usually from values that arrived in a config file, and there is no good failure mode available at that point. Returning an error means every constructor gains an error return and every caller gains a branch. Panicking means a typo in a YAML file takes down a service.

So an out-of-range value is replaced by the default and the middleware runs: a FailureThreshold of 0 becomes 5, a Burst of -1 becomes 100. The result is a working service on a sane policy rather than a dead one or one built on a nonsense number — a Burst of 0 would otherwise reject every request, which is a silent footgun of exactly the kind clamping exists to avoid.

The cost is that a misconfiguration is quiet. Nothing logs the substitution, so a limit you set and a limit you got can differ without evidence. Validate values you care about before you hand them over — the reference tables list what each field clamps to.

The one exception is RetryConfig.MaxRetries, where zero is honoured as "no retries" rather than replaced by the default. Zero is a coherent thing to want from a retry transport, and clamping it would leave no way to express it.