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

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