All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Four reports from docs/issues closed. Every change is additive and backward
compatible: no existing behavior changes unless a new field or hook is set.
ErrKindCircuitOpenandErrKindRateLimitederror kinds.Classifyused to report the package's own sentinels asErrKindUnknown, so a metrics recorder wired above the circuit breaker — the arrangement the documentation suggests — labeled the breaker engaging as an unidentified failure, which is the opposite of what had happened. No retry verdict changes: both kinds are non-retryable, exactly asErrKindUnknownwas, and retrying either would defeat the protection that produced it. The kinds are appended last in theErrorKindblock, so the numeric values shipped in 0.1.0 and 0.2.0 are unchanged.RetryConfig.AttemptTimeoutbounds each individual attempt. A deadline on the caller's context bounds the operation as a whole, so against a dependency that became slow rather than one that fails fast, the first attempt consumed the entire deadline andMaxAttempts: 3produced exactly one request on the wire — with every log and metric reporting a plain timeout. The only cure was composingTimeoutbeneathRetry, a dependency no type expressed and which silently reverted if the middleware order changed. Zero, the default, keeps the previous semantics.CircuitBreakerConfig.OnStateChangereports every state transition. At the defaultSuccessThresholdof 1 the half-open phase begins and ends inside a singleRoundTrip, so no polling frequency can sample it: the transition that shows whether a dependency recovered on its own was unobservable by construction. The callback runs with the breaker's mutex released, on the goroutine of the request that caused the transition, so readingState()from inside it is safe.CircuitBreakerWithStatereturns the middleware together with the breaker it built. The plainCircuitBreakerform discards it, leaving the state of a circuit configured that way unreachable.OnInvalidConfig, a package-level hook called when a constructor receives configuration it cannot apply and falls back to a pass-through.Timeout(0),RateLimit{Limiter: nil}andNewTokenBucket(0, …)used to lose a requested protection in complete silence — a client that looks identical to a correctly configured one until the day the protection was needed. Nil by default, which keeps the previous silence.Metrics{Recorder: nil}andLogging{Logger: nil}stay silent by design: the zero value there means "observability not configured", which is a legitimate default.NewTokenBucketE,NewTokenBucketwith the invalid cases returned as an error wrapping the newErrInvalidRateLimitsentinel, instead of degraded to a bucket that does not limit.
- Doc comments for
Timeout,RateLimitandNewTokenBucketnow describe the no-op as a fallback rather than a project convention, and point atOnInvalidConfig. The previous wording read as a design principle, which is the part that surprised. RetryConfig.MaxAttemptsdocuments that a context deadline bounds the operation, not each attempt, and points atAttemptTimeout.
Classifyis unchanged on the common path: the two sentinels are matched by identity before the transport branches, and byerrors.Isafter them for the wrapped case. Classifying a transport failure stays at ~7.4 ns and zero allocations (measured against ~7.3 ns before the change); an unwrapped sentinel costs ~2.2 ns. Using onlyerrors.Ismeasured 22 ns on the common path when placed first, and 535 ns with 8 allocations on the sentinel path when placed last, so both positions are used deliberately.BenchmarkClassify_Sentinelguards the identity check.- The full middleware stack is unchanged at 12 allocations; the retry and circuit-breaker paths add no allocation when
AttemptTimeoutis zero andOnStateChangeis nil.
0.2.0 - 2026-08-05
ErrKindDNSNotFounderror kind and theIsDNSNotFoundhelper, for a name that does not exist (NXDOMAIN). The kind is appended last in theErrorKindblock, so the numeric values shipped in 0.1.0 are unchanged.
DefaultIsRetryableno longer retries permanent DNS failures.classifyErrornow consultsnet.DNSError.IsNotFound: an NXDOMAIN classifies as the non-retryableErrKindDNSNotFound, while a transient resolution failure staysErrKindDNSand stays retryable. A misspelled or decommissioned hostname previously consumed the whole attempt budget plus the full backoff schedule on an outcome that could never succeed.
IsDNSnow reports true for bothErrKindDNSandErrKindDNSNotFound: an NXDOMAIN is still a DNS failure. Callers that need only the permanent case should useIsDNSNotFound.
- The
Timeoutmiddleware attaches its context withreq.WithContextinstead ofreq.Clone. The deep copy was redundant —Doalready clones the caller's request before the chain runs — and cost a duplicated struct, URL and header map on every request. The full middleware stack drops from 13 to 11 allocations and ~1589 to ~1304 B/op.
0.1.0 - 2026-07-25
First public release.
- Middleware-based HTTP client (
Newreturning*Client,WithMiddleware,WithTransport) built onhttp.RoundTripper, plus the exportedRoundTripperFuncadapter that makes a custom middleware a one-liner. - Resiliency middleware:
Timeout,Retrywith pluggable backoff,CircuitBreaker, andRateLimit(token bucket behind theRateLimiterinterface:TryAcquireplusWaitContext). SharedCircuitBreaker(NewCircuitBreaker) for circuit state shared across multiple clients, with observableState()andCircuitState.String().- Observability middleware:
LoggingandMetrics.MetricsConfig.PathNormalizerbounds metrics label cardinality (raw path is omitted by default). - Backoff strategies: constant, linear, exponential, Fibonacci, and their jitter variants — all zero-alloc and overflow-safe.
BackoffFuncreceives the response that triggered the retry, and theWithRetryAfterdecorator honors theRetry-Afterheader (delay-seconds or HTTP-date) on 429/503. - Fluent request builder (
Client.R) with JSON, XML, form and reader bodies, path parameters, and query parameters. Reader bodies up to 10 MB are buffered so retries can rewind them; larger bodies stream and are sent exactly once. DecodeJSONresponse helper: always drains and closes the body, fails on status >= 300.- Error classification:
Classify,IsRetryable,IsTimeout,IsConnection, and related helpers. - Zero external dependencies; Go standard library only.