v1.1.0
Adds a retry middleware for transient failures (5xx, network errors,
transport-level deadline exceeded), filling the gap between the rate-limit
layer (which owns 429s) and the underlying transport. Composes with the
existing stack without new runtime dependencies.
Added
Retry middleware (retry/)
- New
retry.NewTransport(base, opts...)chainablehttp.RoundTripper.
Defaults: 3 attempts (1 initial + 2 retries), 200ms..2s decorrelated jitter
(per AWS guidance), idempotent methods only (GET/HEAD/OPTIONS/PUT/DELETE),
default predicate retries {500, 502, 503, 504} or*net.OpError/io.EOF/
io.ErrUnexpectedEOF/context.DeadlineExceeded. - Options:
WithMaxAttempts(n)(clamped to [1, 100]),WithBackoff(min, max)
(clamped to [1ms, 1h] with min<=max),WithRetryOn(predicate)(replaces
the default predicate; takes ownership of method-safety),
WithLogger(*slog.Logger). - Top-level
ghkit.WithRetry(opts ...retry.Option)slots the layer between
RateLimit and oauth2 in the chain - 429s never reach retry, and retried
requests get the latest token via oauth2's per-callSource.Token(). - 429 hard-exclusion lives outside the predicate so a user-supplied
WithRetryOncannot accidentally fightratelimit. Retry-Afterhonored (delta-seconds and HTTP-date formats); when it
exceeds the operator'smaxDelaythe call returns
(prior_resp, retry.ErrRetryAfterExceedsMax)and the caller owns
drain+close on the response.- Caller-context cancellation is terminal:
req.Context().Err() != nil
always stops retries before any predicate is consulted, and during
backoff sleep viatime.NewTimer+Stop(no leaked timers on long
Retry-Aftervalues). - Body-bearing retries require
req.GetBody; missing GetBody on a retry
attempt yieldserrors.Join(retry.ErrBodyNotRewindable, prior_err)so
callers see both causes. - Exported predicate primitives -
retry.IsIdempotent(method),
retry.IsRetryable5xx(code),retry.IsTransientNetErr(err)- so callers
composing their ownWithRetryOndon't have to reimplement the defaults. - Panic recovery around user predicates: a panicking
WithRetryOnis
treated as "do not retry" and emits aretry_predicate_panicevent
rather than crashing the transport. - Sanitised structured logging via
slog.Loggerwith explicit per-event
levels:retry_sleep,retry_decision(Debug; silent-success first
attempts skipped),retry_abort,retry_body_unrewindable,
retry_exhausted,retry_predicate_panic(Warn).last_err_typewalks
joined/wrapped error chains so operators see meaningful types instead of
*errors.joinError. - DoS protection: prior-response drain capped at 128 KiB before close,
bounding the time we hold a connection on hostile/oversize bodies.
Documentation
- README transport-stack diagram updated to show retry between RateLimit
and oauth2. - New retry recipe with default and tuned-policy examples (including
Idempotency-Key-based POST opt-in). - New "Things worth knowing" note on retry/throttle interaction (each retry
attempt consumes a throttle token). - Package
doc.goforretry/and updated top-leveldoc.gowith the new
chain layout.
Tooling and CI
- Live integration test (
retry/live_check_test.go, build-taglive,
functionTestRetry_Live) that exercises retry againstapi.github.com. - CI
live-driftjob renamed toLive drift and retry probeand extended
to run bothTestETag_LiveandTestRetry_Live.
Changed
- Silent by default.
ghkit,etag,ratelimit, andretryno longer
default-initialise aslog.Default()logger. Without an explicit
WithLogger(...)call, the library emits no log records. This is a
behaviour change from 1.0.0; users who relied on default stderr output
must now opt in viaghkit.WithLogger(slog.Default())(or any logger). - Per-sub-package
WithLoggeroptions insideWithRetry,WithETagCache,
andWithRateLimitnow correctly override the top-levelWithLogger
instead of being silently shadowed by it. Previously the chain assembly
appended ghkit's logger after user-supplied sub-package options, so
user values lost; the prepend pattern lets user values win. etag.WithLogger(nil)andratelimit.WithLogger(nil)now mean
"explicitly silent" instead of being a no-op. Combined with silent-by-
default, this lets callers composeWithLogger(real)then
WithLogger(nil)to silence on a per-construction basis.retry.IsTransientNetErrnow short-circuits known-permanent failures to
false: DNS NXDOMAIN (*net.DNSError.IsNotFound),syscall.ECONNREFUSED
(TCP RST on connect to a closed port), and x509 cert-validation errors
(x509.UnknownAuthorityError,*x509.HostnameError,
x509.CertificateInvalidError). Misconfigured URLs and expired certs now
fail fast instead of burning the retry budget. Other DNS errors (server
failure, timeout) remain transient.ghkit.WithRateLimitandghkit.WithRateLimitDisabledare now mutually
exclusive. Combining them returnsErrConflictingRateLimitat
construction. Previously the kit logged a warning and silently dropped
the registered callbacks; with silent-by-default that warning would have
been invisible. The hard error matches the existing
ErrConflictingAuthprecedent forWithToken+WithTokenSource.
Added (continued)
- New runnable example at
examples/retry-on-flaky/demonstrating
WithRetrywith a tuned backoff and a custom predicate that opts POST in
viaIdempotency-Key.
Dependencies
No changes. Same as 1.0.0.