Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
962b0ccf17
|
18
CHANGELOG.md
18
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
|
||||
|
||||
4
go.mod
4
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
|
||||
)
|
||||
|
||||
8
go.sum
8
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=
|
||||
|
||||
16
helpers.go
16
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user