feat(httpclient)!: promote to v1.0.0 — retry-on-429 with Retry-After, DoJSONRequest, bump deps

Extend retry loop to handle HTTP 429 Too Many Requests: when the server includes a
Retry-After header, that duration is used as the retry delay; otherwise falls back to
the configured BackOffDelay. Add DoJSONRequest[Req, Resp] free function that serialises
the request body as JSON, sets Content-Type, and delegates response decoding to DoJSON.
Bump logz and xerrors from v0.9.0 to v1.0.0. API committed as stable.
This commit is contained in:
2026-05-11 19:50:16 -06:00
parent 6026ab8a5e
commit 962b0ccf17
6 changed files with 148 additions and 7 deletions

View File

@@ -5,6 +5,24 @@ All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] — 2026-05-12
### Added
- `DoJSONRequest[Req, Resp any](ctx, client, method, rawURL, body) (*Resp, error)` — free generic function that serialises `body` as JSON, constructs the `*http.Request` with `Content-Type: application/json`, and delegates response decoding to `DoJSON`. Complements `DoJSON` for POST/PUT/PATCH calls; use `DoJSON` for GET/DELETE where there is no request body.
### Changed
- Retry loop now handles HTTP 429 Too Many Requests: a 429 response triggers a retry (previously only `>= 500` did). When the server includes a `Retry-After` header with an integer seconds value, that duration is used as the retry delay for that attempt; otherwise the configured `BackOffDelay` applies.
- `logz` and `xerrors` dependencies bumped from v0.9.0 to v1.0.0.
### Unchanged
All other API (`Client`, `Config`, `DefaultConfig`, `New`, `NewWithDefaults`, `DoJSON`,
`MapStatusToError`) is API-compatible with v0.9.0.
[1.0.0]: https://code.nochebuena.dev/go/httpclient/releases/tag/v1.0.0
## [0.9.0] - 2026-03-18 ## [0.9.0] - 2026-03-18
### Added ### Added

4
go.mod
View File

@@ -3,8 +3,8 @@ module code.nochebuena.dev/go/httpclient
go 1.25 go 1.25
require ( require (
code.nochebuena.dev/go/logz v0.9.0 code.nochebuena.dev/go/logz v1.0.0
code.nochebuena.dev/go/xerrors v0.9.0 code.nochebuena.dev/go/xerrors v1.0.0
github.com/avast/retry-go/v4 v4.3.4 github.com/avast/retry-go/v4 v4.3.4
github.com/sony/gobreaker v1.0.0 github.com/sony/gobreaker v1.0.0
) )

8
go.sum
View File

@@ -1,7 +1,7 @@
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE= code.nochebuena.dev/go/logz v1.0.0 h1:DpNvLuVFqyLSVKxaRa799sG8RpHnm1j6dhu4pKiFOvY=
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8= code.nochebuena.dev/go/logz v1.0.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE= code.nochebuena.dev/go/xerrors v1.0.0 h1:si24SFGa7cHwAxbu75AAEB+a3qRmF118F/BM2SFI7VI=
code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY= code.nochebuena.dev/go/xerrors v1.0.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
github.com/avast/retry-go/v4 v4.3.4 h1:pHLkL7jvCvP317I8Ge+Km2Yhntv3SdkJm7uekkqbKhM= github.com/avast/retry-go/v4 v4.3.4 h1:pHLkL7jvCvP317I8Ge+Km2Yhntv3SdkJm7uekkqbKhM=
github.com/avast/retry-go/v4 v4.3.4/go.mod h1:rv+Nla6Vk3/ilU0H51VHddWHiwimzX66yZ0JT6T+UvE= github.com/avast/retry-go/v4 v4.3.4/go.mod h1:rv+Nla6Vk3/ilU0H51VHddWHiwimzX66yZ0JT6T+UvE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@@ -1,6 +1,7 @@
package httpclient package httpclient
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
@@ -36,6 +37,21 @@ func DoJSON[T any](ctx context.Context, client Client, req *http.Request) (*T, e
return &data, nil return &data, nil
} }
// DoJSONRequest serialises body as JSON, POST/PUT/PATCHes it to rawURL, and
// decodes the response into Resp. For requests without a body, use DoJSON instead.
func DoJSONRequest[Req, Resp any](ctx context.Context, client Client, method, rawURL string, body Req) (*Resp, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, xerrors.New(xerrors.ErrInternal, "failed to encode request body").WithError(err)
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(data))
if err != nil {
return nil, xerrors.New(xerrors.ErrInternal, "failed to create request").WithError(err)
}
req.Header.Set("Content-Type", "application/json")
return DoJSON[Resp](ctx, client, req)
}
// MapStatusToError maps an HTTP status code to the matching xerrors type. // MapStatusToError maps an HTTP status code to the matching xerrors type.
func MapStatusToError(code int, msg string) error { func MapStatusToError(code int, msg string) error {
switch code { switch code {

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strconv"
"time" "time"
retry "github.com/avast/retry-go/v4" retry "github.com/avast/retry-go/v4"
@@ -91,6 +92,7 @@ func (c *httpClient) Do(req *http.Request) (*http.Response, error) {
result, err := c.cb.Execute(func() (any, error) { result, err := c.cb.Execute(func() (any, error) {
var innerErr error var innerErr error
var retryAfterDelay time.Duration
retryErr := retry.Do( retryErr := retry.Do(
func() error { func() error {
if id := logz.GetRequestID(req.Context()); id != "" { if id := logz.GetRequestID(req.Context()); id != "" {
@@ -110,6 +112,14 @@ func (c *httpClient) Do(req *http.Request) (*http.Response, error) {
"status", resp.StatusCode, "status", resp.StatusCode,
"latency", latency.String(), "latency", latency.String(),
) )
if resp.StatusCode == http.StatusTooManyRequests {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil {
retryAfterDelay = time.Duration(secs) * time.Second
}
}
return fmt.Errorf("rate limited: %d", resp.StatusCode)
}
if resp.StatusCode >= 500 { if resp.StatusCode >= 500 {
return fmt.Errorf("server error: %d", resp.StatusCode) return fmt.Errorf("server error: %d", resp.StatusCode)
} }
@@ -117,7 +127,14 @@ func (c *httpClient) Do(req *http.Request) (*http.Response, error) {
}, },
retry.Attempts(c.cfg.MaxRetries), retry.Attempts(c.cfg.MaxRetries),
retry.Delay(c.cfg.RetryDelay), retry.Delay(c.cfg.RetryDelay),
retry.DelayType(retry.BackOffDelay), retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration {
if retryAfterDelay > 0 {
d := retryAfterDelay
retryAfterDelay = 0
return d
}
return retry.BackOffDelay(n, err, config)
}),
retry.LastErrorOnly(true), retry.LastErrorOnly(true),
) )
return resp, retryErr return resp, retryErr

View File

@@ -111,6 +111,96 @@ func TestClient_Do_NoRequestID(t *testing.T) {
} }
} }
func TestClient_Do_Retry429_WithRetryAfter(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 3, RetryDelay: 1 * time.Millisecond, CBThreshold: 100, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("expected error after retries")
}
if calls < 2 {
t.Errorf("expected retries on 429, got %d calls", calls)
}
}
func TestClient_Do_Retry429_NoRetryAfter(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 3, RetryDelay: 1 * time.Millisecond, CBThreshold: 100, CBTimeout: time.Minute,
})
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("expected error after retries")
}
if calls < 2 {
t.Errorf("expected retries on 429, got %d calls", calls)
}
}
func TestDoJSONRequest_Success(t *testing.T) {
type payload struct{ Name string }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("want Content-Type application/json, got %q", ct)
}
var req payload
_ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(payload{Name: "echo:" + req.Name})
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
result, err := DoJSONRequest[payload, payload](t.Context(), client, http.MethodPost, srv.URL, payload{Name: "alice"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "echo:alice" {
t.Errorf("want echo:alice, got %s", result.Name)
}
}
func TestDoJSONRequest_4xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client := New(newLogger(), Config{
Name: "test", Timeout: 5 * time.Second, DialTimeout: 2 * time.Second,
MaxRetries: 1, RetryDelay: time.Millisecond, CBThreshold: 10, CBTimeout: time.Minute,
})
_, err := DoJSONRequest[struct{}, struct{}](t.Context(), client, http.MethodPost, srv.URL, struct{}{})
if err == nil {
t.Fatal("expected error")
}
var xe *xerrors.Err
if !errors.As(err, &xe) || xe.Code() != xerrors.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestDoJSON_Success(t *testing.T) { func TestDoJSON_Success(t *testing.T) {
type payload struct{ Name string } type payload struct{ Name string }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {