From 962b0ccf172a8aa3228b5afa9e7d7fe53e09bd35 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Mon, 11 May 2026 19:50:16 -0600 Subject: [PATCH] =?UTF-8?q?feat(httpclient)!:=20promote=20to=20v1.0.0=20?= =?UTF-8?q?=E2=80=94=20retry-on-429=20with=20Retry-After,=20DoJSONRequest,?= =?UTF-8?q?=20bump=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 18 ++++++++++ go.mod | 4 +-- go.sum | 8 ++--- helpers.go | 16 +++++++++ httpclient.go | 19 +++++++++- httpclient_test.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bec5133..200e30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/go.mod b/go.mod index bb25c1e..6a86ffa 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module code.nochebuena.dev/go/httpclient go 1.25 require ( - code.nochebuena.dev/go/logz v0.9.0 - code.nochebuena.dev/go/xerrors v0.9.0 + code.nochebuena.dev/go/logz v1.0.0 + code.nochebuena.dev/go/xerrors v1.0.0 github.com/avast/retry-go/v4 v4.3.4 github.com/sony/gobreaker v1.0.0 ) diff --git a/go.sum b/go.sum index e6a3bd9..122f2e2 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE= -code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8= -code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE= -code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY= +code.nochebuena.dev/go/logz v1.0.0 h1:DpNvLuVFqyLSVKxaRa799sG8RpHnm1j6dhu4pKiFOvY= +code.nochebuena.dev/go/logz v1.0.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8= +code.nochebuena.dev/go/xerrors v1.0.0 h1:si24SFGa7cHwAxbu75AAEB+a3qRmF118F/BM2SFI7VI= +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/go.mod h1:rv+Nla6Vk3/ilU0H51VHddWHiwimzX66yZ0JT6T+UvE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/helpers.go b/helpers.go index 7449f8b..7dac19f 100644 --- a/helpers.go +++ b/helpers.go @@ -1,6 +1,7 @@ package httpclient import ( + "bytes" "context" "encoding/json" "io" @@ -36,6 +37,21 @@ func DoJSON[T any](ctx context.Context, client Client, req *http.Request) (*T, e 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. func MapStatusToError(code int, msg string) error { switch code { diff --git a/httpclient.go b/httpclient.go index 5690913..14b8d62 100644 --- a/httpclient.go +++ b/httpclient.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "strconv" "time" 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) { var innerErr error + var retryAfterDelay time.Duration retryErr := retry.Do( func() error { if id := logz.GetRequestID(req.Context()); id != "" { @@ -110,6 +112,14 @@ func (c *httpClient) Do(req *http.Request) (*http.Response, error) { "status", resp.StatusCode, "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 { 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.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), ) return resp, retryErr diff --git a/httpclient_test.go b/httpclient_test.go index 59ecd83..df1afd1 100644 --- a/httpclient_test.go +++ b/httpclient_test.go @@ -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) { type payload struct{ Name string } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {