1 Commits

Author SHA1 Message Date
962b0ccf17 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.
2026-05-11 19:50:16 -06:00
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/),
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

4
go.mod
View File

@@ -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
)

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 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=

View File

@@ -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 {

View File

@@ -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

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) {
type payload struct{ Name string }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {