mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-10 23:29:22 +00:00
fixes
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/errors"
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/metrics"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Client is an HTTP client with resilience features
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
rateLimiter *rate.Limiter
|
||||
circuitBreaker *CircuitBreaker
|
||||
retryConfig RetryConfig
|
||||
}
|
||||
|
||||
// Config holds client configuration
|
||||
type Config struct {
|
||||
Timeout time.Duration // Request timeout
|
||||
MaxRetries int // Max retry attempts
|
||||
RetryDelay time.Duration // Initial retry delay
|
||||
RateLimit float64 // Requests per second (0 = unlimited)
|
||||
RateBurst int // Rate limiter burst
|
||||
CircuitBreaker CircuitBreakerConfig
|
||||
UserAgent string
|
||||
MaxConnsPerHost int
|
||||
}
|
||||
|
||||
// RetryConfig holds retry configuration
|
||||
type RetryConfig struct {
|
||||
MaxAttempts int
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
Multiplier float64
|
||||
FixedDelays []time.Duration // If set, use these delays instead of exponential backoff
|
||||
}
|
||||
|
||||
// CircuitBreakerConfig holds circuit breaker configuration
|
||||
type CircuitBreakerConfig struct {
|
||||
Enabled bool
|
||||
FailureThreshold int // Failures before opening
|
||||
SuccessThreshold int // Successes before closing
|
||||
Timeout time.Duration // How long to stay open
|
||||
HalfOpenMaxCalls int // Max calls in half-open state
|
||||
}
|
||||
|
||||
// CircuitBreakerState represents circuit breaker state
|
||||
type CircuitBreakerState int
|
||||
|
||||
const (
|
||||
StateClosed CircuitBreakerState = iota
|
||||
StateOpen
|
||||
StateHalfOpen
|
||||
)
|
||||
|
||||
// CircuitBreaker implements the circuit breaker pattern
|
||||
type CircuitBreaker struct {
|
||||
config CircuitBreakerConfig
|
||||
state CircuitBreakerState
|
||||
failures int
|
||||
successes int
|
||||
lastFailureTime time.Time
|
||||
halfOpenCalls int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new HTTP client with resilience features
|
||||
func NewClient(config Config) *Client {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
if config.MaxRetries == 0 {
|
||||
config.MaxRetries = 3
|
||||
}
|
||||
|
||||
if config.RetryDelay == 0 {
|
||||
config.RetryDelay = 1 * time.Second
|
||||
}
|
||||
|
||||
if config.UserAgent == "" {
|
||||
config.UserAgent = "GoHoarder/1.0"
|
||||
}
|
||||
|
||||
if config.MaxConnsPerHost == 0 {
|
||||
config.MaxConnsPerHost = 100
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: config.MaxConnsPerHost,
|
||||
MaxConnsPerHost: config.MaxConnsPerHost,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
var rateLimiter *rate.Limiter
|
||||
if config.RateLimit > 0 {
|
||||
if config.RateBurst == 0 {
|
||||
config.RateBurst = int(config.RateLimit)
|
||||
}
|
||||
rateLimiter = rate.NewLimiter(rate.Limit(config.RateLimit), config.RateBurst)
|
||||
}
|
||||
|
||||
var cb *CircuitBreaker
|
||||
if config.CircuitBreaker.Enabled {
|
||||
if config.CircuitBreaker.FailureThreshold == 0 {
|
||||
config.CircuitBreaker.FailureThreshold = 5
|
||||
}
|
||||
if config.CircuitBreaker.SuccessThreshold == 0 {
|
||||
config.CircuitBreaker.SuccessThreshold = 2
|
||||
}
|
||||
if config.CircuitBreaker.Timeout == 0 {
|
||||
config.CircuitBreaker.Timeout = 60 * time.Second
|
||||
}
|
||||
if config.CircuitBreaker.HalfOpenMaxCalls == 0 {
|
||||
config.CircuitBreaker.HalfOpenMaxCalls = 3
|
||||
}
|
||||
|
||||
cb = &CircuitBreaker{
|
||||
config: config.CircuitBreaker,
|
||||
state: StateClosed,
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
client: httpClient,
|
||||
rateLimiter: rateLimiter,
|
||||
circuitBreaker: cb,
|
||||
retryConfig: RetryConfig{
|
||||
MaxAttempts: config.MaxRetries,
|
||||
InitialDelay: config.RetryDelay,
|
||||
MaxDelay: 30 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
// Fixed delays: 1s, 5s, 10s for retry attempts 1, 2, 3
|
||||
FixedDelays: []time.Duration{1 * time.Second, 5 * time.Second, 10 * time.Second},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get performs a GET request with resilience features
|
||||
func (c *Client) Get(ctx context.Context, url string, headers map[string]string) (io.ReadCloser, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(err, errors.ErrCodeUpstreamError, "failed to create request")
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := c.do(ctx, req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return resp.Body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// do executes an HTTP request with retries and circuit breaker
|
||||
func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
// Check circuit breaker
|
||||
if c.circuitBreaker != nil {
|
||||
if !c.circuitBreaker.AllowRequest() {
|
||||
metrics.UpdateCircuitBreakerState("upstream", int(StateOpen))
|
||||
return nil, errors.New(errors.ErrCodeCircuitOpen, "circuit breaker is open")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rate limiting
|
||||
if c.rateLimiter != nil {
|
||||
if err := c.rateLimiter.Wait(ctx); err != nil {
|
||||
return nil, errors.Wrap(err, errors.ErrCodeRateLimited, "rate limit exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Execute with retries
|
||||
var lastErr error
|
||||
delay := c.retryConfig.InitialDelay
|
||||
|
||||
for attempt := 0; attempt < c.retryConfig.MaxAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Calculate delay: use fixed delays if configured, otherwise exponential backoff
|
||||
if len(c.retryConfig.FixedDelays) > 0 {
|
||||
// Use fixed delay schedule
|
||||
delayIndex := attempt - 1
|
||||
if delayIndex < len(c.retryConfig.FixedDelays) {
|
||||
delay = c.retryConfig.FixedDelays[delayIndex]
|
||||
} else {
|
||||
// Use last delay if we run out of configured delays
|
||||
delay = c.retryConfig.FixedDelays[len(c.retryConfig.FixedDelays)-1]
|
||||
}
|
||||
} else {
|
||||
// Exponential backoff
|
||||
delay = time.Duration(float64(delay) * c.retryConfig.Multiplier)
|
||||
if delay > c.retryConfig.MaxDelay {
|
||||
delay = c.retryConfig.MaxDelay
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("url", req.URL.String()).
|
||||
Int("attempt", attempt+1).
|
||||
Dur("delay", delay).
|
||||
Msg("Retrying request")
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if c.circuitBreaker != nil {
|
||||
c.circuitBreaker.RecordFailure()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if response is retryable
|
||||
if c.isRetryable(resp.StatusCode) {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("received retryable status code: %d", resp.StatusCode)
|
||||
if c.circuitBreaker != nil {
|
||||
c.circuitBreaker.RecordFailure()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Success
|
||||
if c.circuitBreaker != nil {
|
||||
c.circuitBreaker.RecordSuccess()
|
||||
metrics.UpdateCircuitBreakerState("upstream", int(StateClosed))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if c.circuitBreaker != nil {
|
||||
c.circuitBreaker.RecordFailure()
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, errors.Wrap(lastErr, errors.ErrCodeUpstreamFailure, "all retry attempts failed")
|
||||
}
|
||||
|
||||
return nil, errors.New(errors.ErrCodeUpstreamFailure, "request failed without error")
|
||||
}
|
||||
|
||||
// isRetryable checks if a status code should trigger a retry
|
||||
func (c *Client) isRetryable(statusCode int) bool {
|
||||
// Retry on server errors and some client errors
|
||||
return statusCode >= 500 || statusCode == 408 || statusCode == 429
|
||||
}
|
||||
|
||||
// AllowRequest checks if a request is allowed by the circuit breaker
|
||||
func (cb *CircuitBreaker) AllowRequest() bool {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
return true
|
||||
|
||||
case StateOpen:
|
||||
// Check if timeout has elapsed
|
||||
if time.Since(cb.lastFailureTime) > cb.config.Timeout {
|
||||
cb.state = StateHalfOpen
|
||||
cb.halfOpenCalls = 0
|
||||
cb.successes = 0
|
||||
log.Info().Msg("Circuit breaker transitioning to half-open")
|
||||
metrics.UpdateCircuitBreakerState("upstream", int(StateHalfOpen))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
case StateHalfOpen:
|
||||
// Allow limited requests in half-open state
|
||||
if cb.halfOpenCalls < cb.config.HalfOpenMaxCalls {
|
||||
cb.halfOpenCalls++
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RecordSuccess records a successful request
|
||||
func (cb *CircuitBreaker) RecordSuccess() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
cb.failures = 0
|
||||
|
||||
case StateHalfOpen:
|
||||
cb.successes++
|
||||
if cb.successes >= cb.config.SuccessThreshold {
|
||||
cb.state = StateClosed
|
||||
cb.failures = 0
|
||||
cb.successes = 0
|
||||
cb.halfOpenCalls = 0
|
||||
log.Info().Msg("Circuit breaker closed")
|
||||
metrics.UpdateCircuitBreakerState("upstream", int(StateClosed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecordFailure records a failed request
|
||||
func (cb *CircuitBreaker) RecordFailure() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.lastFailureTime = time.Now()
|
||||
|
||||
switch cb.state {
|
||||
case StateClosed:
|
||||
cb.failures++
|
||||
if cb.failures >= cb.config.FailureThreshold {
|
||||
cb.state = StateOpen
|
||||
log.Warn().Int("failures", cb.failures).Msg("Circuit breaker opened")
|
||||
metrics.UpdateCircuitBreakerState("upstream", int(StateOpen))
|
||||
}
|
||||
|
||||
case StateHalfOpen:
|
||||
// Single failure in half-open returns to open
|
||||
cb.state = StateOpen
|
||||
cb.halfOpenCalls = 0
|
||||
cb.successes = 0
|
||||
log.Warn().Msg("Circuit breaker re-opened from half-open")
|
||||
metrics.UpdateCircuitBreakerState("upstream", int(StateOpen))
|
||||
}
|
||||
}
|
||||
|
||||
// GetState returns the current circuit breaker state
|
||||
func (cb *CircuitBreaker) GetState() CircuitBreakerState {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
return cb.state
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package network_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/gohoarder/pkg/network"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestClientGet tests the HTTP client Get method with various scenarios
|
||||
func TestClientGet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverBehavior func(*testing.T) *httptest.Server
|
||||
config network.Config
|
||||
headers map[string]string
|
||||
wantErr bool
|
||||
errContains string
|
||||
validateBody func(*testing.T, io.ReadCloser)
|
||||
validateStatus func(*testing.T, int)
|
||||
}{
|
||||
// GOOD: Successful GET request
|
||||
{
|
||||
name: "successful get request returns body",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success"))
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 3,
|
||||
},
|
||||
validateBody: func(t *testing.T, body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "success", string(data))
|
||||
},
|
||||
validateStatus: func(t *testing.T, status int) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
},
|
||||
},
|
||||
// GOOD: Retry succeeds on second attempt
|
||||
{
|
||||
name: "retry succeeds after transient failure",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
var attemptCount int32
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt32(&attemptCount, 1)
|
||||
if count == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("retry-success"))
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: 10 * time.Millisecond,
|
||||
},
|
||||
validateBody: func(t *testing.T, body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "retry-success", string(data))
|
||||
},
|
||||
validateStatus: func(t *testing.T, status int) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
},
|
||||
},
|
||||
// GOOD: Headers are properly sent
|
||||
{
|
||||
name: "custom headers are sent correctly",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "application/json", r.Header.Get("Accept"))
|
||||
assert.Equal(t, "Bearer token123", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 1,
|
||||
},
|
||||
headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer token123",
|
||||
},
|
||||
validateStatus: func(t *testing.T, status int) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
},
|
||||
},
|
||||
// WRONG: Server returns 404 (non-retryable)
|
||||
{
|
||||
name: "404 error is not retried",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
var attemptCount int32
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&attemptCount, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: 10 * time.Millisecond,
|
||||
},
|
||||
validateStatus: func(t *testing.T, status int) {
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
},
|
||||
},
|
||||
// WRONG: Server returns 429 (rate limited - retryable)
|
||||
{
|
||||
name: "429 rate limit triggers retry with fixed delays",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
var attemptCount int32
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt32(&attemptCount, 1)
|
||||
if count <= 2 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success-after-rate-limit"))
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: 10 * time.Millisecond,
|
||||
},
|
||||
validateBody: func(t *testing.T, body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "success-after-rate-limit", string(data))
|
||||
},
|
||||
},
|
||||
// BAD: All retries exhausted
|
||||
{
|
||||
name: "all retries fail returns error",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 2,
|
||||
RetryDelay: 10 * time.Millisecond,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "retry attempts failed",
|
||||
},
|
||||
// BAD: Server timeout
|
||||
{
|
||||
name: "server timeout returns error",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 50 * time.Millisecond,
|
||||
MaxRetries: 1,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "context deadline exceeded",
|
||||
},
|
||||
// EDGE 1: Context timeout (deadline exceeded)
|
||||
{
|
||||
name: "context timeout stops retry",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 5,
|
||||
RetryDelay: 50 * time.Millisecond,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "context deadline exceeded",
|
||||
},
|
||||
// EDGE 2: Empty response body
|
||||
{
|
||||
name: "empty response body handled correctly",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 1,
|
||||
},
|
||||
validateBody: func(t *testing.T, body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, data)
|
||||
},
|
||||
},
|
||||
// EDGE 3: Large response body
|
||||
{
|
||||
name: "large response body handled correctly",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
largeBody := strings.Repeat("a", 1024*1024) // 1MB
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(largeBody))
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRetries: 1,
|
||||
},
|
||||
validateBody: func(t *testing.T, body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, data, 1024*1024)
|
||||
},
|
||||
},
|
||||
// EDGE 4: Circuit breaker enabled
|
||||
{
|
||||
name: "circuit breaker opens after failures",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 2,
|
||||
RetryDelay: 10 * time.Millisecond,
|
||||
CircuitBreaker: network.CircuitBreakerConfig{
|
||||
Enabled: true,
|
||||
FailureThreshold: 3,
|
||||
SuccessThreshold: 2,
|
||||
Timeout: 100 * time.Millisecond,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "retry attempts failed",
|
||||
},
|
||||
// EDGE 5: Rate limiting enabled
|
||||
{
|
||||
name: "rate limiter throttles requests",
|
||||
serverBehavior: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
},
|
||||
config: network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 1,
|
||||
RateLimit: 10, // 10 req/sec
|
||||
RateBurst: 1,
|
||||
},
|
||||
validateStatus: func(t *testing.T, status int) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Arrange
|
||||
server := tt.serverBehavior(t)
|
||||
defer server.Close()
|
||||
|
||||
client := network.NewClient(tt.config)
|
||||
ctx := context.Background()
|
||||
|
||||
// For context timeout test
|
||||
if strings.Contains(tt.name, "context timeout") {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// Act
|
||||
body, status, err := client.Get(ctx, server.URL, tt.headers)
|
||||
|
||||
// Assert
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, body)
|
||||
|
||||
if tt.validateBody != nil {
|
||||
tt.validateBody(t, body)
|
||||
} else {
|
||||
body.Close()
|
||||
}
|
||||
|
||||
if tt.validateStatus != nil {
|
||||
tt.validateStatus(t, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryDelays verifies fixed retry delays are used correctly
|
||||
func TestRetryDelays(t *testing.T) {
|
||||
var attemptTimes []time.Time
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptTimes = append(attemptTimes, time.Now())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := network.NewClient(network.Config{
|
||||
Timeout: 10 * time.Second,
|
||||
MaxRetries: 3,
|
||||
RetryDelay: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
_, _, err := client.Get(ctx, server.URL, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Len(t, attemptTimes, 3, "should have made exactly 3 attempts")
|
||||
|
||||
// Verify delays are approximately 1s, 5s, 10s (with some tolerance)
|
||||
// Note: The actual implementation uses fixed delays [1s, 5s, 10s]
|
||||
// but for this test we're using RetryDelay as base which would be used
|
||||
// if FixedDelays wasn't set
|
||||
}
|
||||
|
||||
// TestConcurrentRequests verifies the client is safe for concurrent use
|
||||
func TestConcurrentRequests(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("concurrent-ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := network.NewClient(network.Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetries: 1,
|
||||
})
|
||||
|
||||
const concurrent = 10
|
||||
errs := make(chan error, concurrent)
|
||||
|
||||
// Launch concurrent requests
|
||||
for i := 0; i < concurrent; i++ {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
body, status, err := client.Get(ctx, server.URL, nil)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
if status != http.StatusOK {
|
||||
errs <- fmt.Errorf("unexpected status: %d", status)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
|
||||
if string(data) != "concurrent-ok" {
|
||||
errs <- fmt.Errorf("unexpected body: %s", data)
|
||||
return
|
||||
}
|
||||
|
||||
errs <- nil
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
for i := 0; i < concurrent; i++ {
|
||||
err := <-errs
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user