Second release of rhttp. One correctness fix in the error taxonomy and one allocation removed from the hot path. Still zero dependencies, still Go 1.21+.
Permanent DNS failures are no longer retried
ErrKindDNS was retryable unconditionally, so DefaultIsRetryable retried NXDOMAIN — a failure that is permanent by definition. A misspelled hostname or a DNS entry removed during a migration burned the entire attempt budget plus the full backoff schedule on an outcome that was already decided at the first lookup. The resolver had been reporting net.DNSError.IsNotFound == true the whole time; nothing read it.
Against this-host-does-not-exist.invalid with MaxAttempts: 4 and a 100 ms constant backoff:
| v0.1.0 | v0.2.0 | |
|---|---|---|
Classify(err).Kind |
dns |
dns_not_found |
Kind.IsRetryable() |
true |
false |
| transport attempts | 4 | 1 |
| wall clock | 304 ms | 0 s |
The fix lives in the classification, not in the retry predicate. classifyError now consults IsNotFound and returns a distinct ErrKindDNSNotFound that is excluded from IsRetryable — which means all three public paths agree at once: DefaultIsRetryable, the package-level IsRetryable(err), and Classify(err).Kind.IsRetryable(). Special-casing the error inside the retry middleware would have left the other two still answering true.
ErrKindDNS was deliberately not flipped wholesale. DNS timeouts already classify as ErrKindTimeout, so what remains under ErrKindDNS is a genuine mix — SERVFAIL and friends stay retryable, as they should. Same distinction the AWS SDK for Go v2 and gRPC-Go make.
Lower allocation floor
The Timeout middleware deep-copied the request when all it needed was to attach a context. The copy was redundant: Do already clones the caller's request before the chain runs, so mid-chain independence protected nobody — it just duplicated the struct, the URL and the header map on every request.
| Benchmark | v0.1.0 | v0.2.0 |
|---|---|---|
| Full middleware stack | 13 allocs / 1589 B | 11 allocs / 1304 B |
| Timeout only | 12 allocs | 10 allocs |
| As a wrapper vs competitors | 12 allocs | 10 allocs |
| End-to-end (loopback) | 76 allocs | 74 allocs |
Measured 2026-08-05, linux/amd64, i7-1255U, min of 5 runs. Full tables and methodology: benchmarks/REPORT.md.
Upgrading
Minor rather than patch, because behavior changes without you touching a line:
- NXDOMAIN is no longer retried. If you were relying on those retries, they are gone. This is the point of the release.
IsDNS(err)now matches both DNS kinds, transient and permanent. It stays true for NXDOMAIN — an NXDOMAIN is still a DNS failure, and making it false would have broken existing callers silently. Use the newIsDNSNotFound(err)when you specifically want the permanent case.ErrKindDNSNotFoundis appended last in theErrorKindblock, so the numeric values shipped in 0.1.0 do not shift.
No API was removed. If you already narrowed retries with a custom IsRetryable, nothing changes for you.
Quality
build,vetandtest -racegreen on Go 1.21 (thego.modminimum), 1.22 and 1.23 in CI.- New tests cover NXDOMAIN classification, the
*url.Error → *net.OpError → *net.DNSErrorchain as the error actually arrives from the transport, transient-DNS-stays-retryable, the retry attempt-count regression, and the invariant thatDoshields the caller's request from middleware mutation.
Install
go get github.com/oswaldom-code/rhttp@v0.2.0
Full changelog: CHANGELOG.md