Releases: oswaldom-code/rhttp
Release list
rhttp v0.2.0 — NXDOMAIN no longer retried
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
rhttp v0.1.0 — resilient HTTP client, zero dependencies
First public release of rhttp: a production-grade HTTP client for Go with built-in resiliency patterns and zero dependencies — Go standard library only.
Highlights
- Middleware architecture — every capability is an
http.RoundTripperdecorator, composed withNew(WithMiddleware(...)). Writing your own middleware is a one-liner with the exportedRoundTripperFunc. - Resiliency —
Timeout,Retrywith pluggable backoff,CircuitBreaker(single-probe half-open, generation-gated state machine), andRateLimit(token bucket). - Retry-After aware — backoff strategies receive the response that triggered the retry; the
WithRetryAfterdecorator honors theRetry-Afterheader (delay-seconds or HTTP-date) on 429/503. - Shared circuit state —
NewCircuitBreakerlets several clients trip together against the same dependency, with observableState(). - Observability —
LoggingandMetricsmiddleware;PathNormalizerbounds metrics label cardinality. - Fluent builder —
client.R()with JSON/XML/form/reader bodies, path and query parameters. Bodies up to 10 MB are buffered so retries can rewind them; larger bodies stream and are sent exactly once. - Error classification —
Classify,IsRetryable,IsTimeout,IsConnectionand friends, plus the opinionatedDecodeJSONhelper.
Performance
Measured 2026-07-25 (linux/amd64, i7-1255U, min of 5 runs; see the README for methodology and caveats):
| ns/op | allocs/op | |
|---|---|---|
| Full middleware stack (no-op transport) | 1030 | 13 |
| As a wrapper vs equivalently configured competitors | 910 | 12 |
| net/http floor (timeout only, no retry) | 1750 | 26 |
| Rate limiter check | 48 | 0 |
| Backoff strategies | <10 | 0 |
Loopback numbers amplify relative differences — against a real network (0.5–500 ms) every client performs the same for practical purposes. Full tables and reproduction steps: benchmarks/REPORT.md.
Quality
- 95.2% statement coverage on the root package; race-clean suite across Go 1.21–1.23 in CI.
- Middleware interaction semantics (timeout budgets, per-attempt deadlines, breaker cutting retries) pinned by tests.
Install
go get github.com/oswaldom-code/rhttp@v0.1.0
Full changelog: CHANGELOG.md