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/),
|
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
4
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||||
|
|||||||
16
helpers.go
16
helpers.go
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user