first commit of restructured project

This commit is contained in:
KS Jannette
2026-02-22 15:21:18 -05:00
commit 9fca234606
75 changed files with 8299 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
package httputil
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
var DefaultRetry = RetryConfig{
MaxAttempts: 3,
BaseDelay: 1 * time.Second,
MaxDelay: 10 * time.Second,
}
// Do executes an HTTP request with exponential backoff retry.
// The buildReq function is called on each attempt to produce a fresh request
// (required because request bodies are consumed on each attempt).
func Do(ctx context.Context, client *http.Client, cfg RetryConfig, buildReq func() (*http.Request, error)) (*http.Response, error) {
if cfg.MaxAttempts <= 0 {
cfg.MaxAttempts = DefaultRetry.MaxAttempts
}
var lastErr error
delay := cfg.BaseDelay
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
req, err := buildReq()
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if err != nil {
lastErr = err
} else {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
if attempt == cfg.MaxAttempts {
break
}
fmt.Printf("[RETRY] Attempt %d/%d failed: %v — retrying in %s\n",
attempt, cfg.MaxAttempts, lastErr, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
delay *= 2
if delay > cfg.MaxDelay {
delay = cfg.MaxDelay
}
}
return nil, fmt.Errorf("all %d attempts failed, last error: %w", cfg.MaxAttempts, lastErr)
}

View File

@@ -0,0 +1,136 @@
package httputil
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestDo_SuccessFirstAttempt(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 100 * time.Millisecond, MaxDelay: 1 * time.Second}
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestDo_RetriesOnServerError(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if n < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on third attempt, got %d", resp.StatusCode)
}
if attempts.Load() != 3 {
t.Fatalf("expected 3 attempts, got %d", attempts.Load())
}
}
func TestDo_AllAttemptsFail(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("upstream error"))
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
_, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err == nil {
t.Fatal("expected error after all attempts failed")
}
if attempts.Load() != 3 {
t.Fatalf("expected 3 attempts, got %d", attempts.Load())
}
t.Logf("Error after retries: %v", err)
}
func TestDo_NoRetryOnClientError(t *testing.T) {
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 3, BaseDelay: 50 * time.Millisecond, MaxDelay: 200 * time.Millisecond}
resp, err := Do(context.Background(), client, cfg, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, srv.URL, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if attempts.Load() != 1 {
t.Fatalf("should not retry on 4xx, expected 1 attempt, got %d", attempts.Load())
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestDo_RespectsContextCancellation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
client := &http.Client{Timeout: 5 * time.Second}
cfg := RetryConfig{MaxAttempts: 10, BaseDelay: 500 * time.Millisecond, MaxDelay: 2 * time.Second}
_, err := Do(ctx, client, cfg, func() (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
})
if err == nil {
t.Fatal("expected error from context cancellation")
}
t.Logf("Cancelled: %v", err)
}