diff --git a/proxy/helpers_test.go b/proxy/helpers_test.go index b98066e..052e565 100644 --- a/proxy/helpers_test.go +++ b/proxy/helpers_test.go @@ -1,4 +1,4 @@ -package proxy +package main import ( "encoding/json" diff --git a/proxy/retry_validation_test.go b/proxy/retry_validation_test.go new file mode 100644 index 0000000..69056d5 --- /dev/null +++ b/proxy/retry_validation_test.go @@ -0,0 +1,1117 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// TestRetryValidation429WithRetryAfter tests that 429 with Retry-After header +// honors the delay before retrying, then returns success to the client. +func TestRetryValidation429WithRetryAfter(t *testing.T) { + maxRetries := 2 + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 429 twice, then success + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + if count <= 2 { + w.Header().Set("Retry-After", "1") // 1 second wait + w.WriteHeader(http.StatusTooManyRequests) + return + } + // Success after retries + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "Success"}}, + }) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + // Create test request + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Record start time to verify retry-after delay + start := time.Now() + + // Call the proxy handler + handler.ServeHTTP(w, req) + + duration := time.Since(start) + + // Verify response - read from recorder body, not response body + body := w.Body.String() + if !json.Valid([]byte(body)) { + t.Errorf("Response body is not valid JSON: %s", body) + } + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify upstream call count (1 initial + 2 retries = 3 total) + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + // Verify total duration is at least 2 seconds (Retry-After: 1 twice) + if duration < 2*time.Second { + t.Logf("WARNING: Duration %v is less than expected 2s", duration) + } + + t.Logf("PASS: %d upstream calls in %v", actualCalls, duration) +} + +// TestRetryValidation429NoHeader tests that 429 without Retry-After header +// uses exponential backoff and eventually surfaces the 429. +func TestRetryValidation429NoHeader(t *testing.T) { + maxRetries := 1 // Small for fast test + var upstreamCallCount atomic.Int32 + + // Create upstream that always returns 429 without Retry-After + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.WriteHeader(http.StatusTooManyRequests) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Record start time + start := time.Now() + + handler.ServeHTTP(w, req) + + duration := time.Since(start) + + // Verify 429 is returned to client + if w.Code != http.StatusTooManyRequests { + t.Errorf("Expected status 429, got %d", w.Code) + } + + // Verify upstream call count (1 initial + 1 retry = 2 total with maxRetries=1) + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + // Verify exponential backoff: first retry waits 1s + if duration < 1*time.Second { + t.Logf("WARNING: Duration %v is less than expected 1s", duration) + } + + t.Logf("PASS: %d upstream calls in %v", actualCalls, duration) +} + +// TestRetryValidation422NoRetry tests that 422 responses are NOT retried. +func TestRetryValidation422NoRetry(t *testing.T) { + maxRetries := 5 // High to ensure no retry happens + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 422 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + t.Logf("Upstream call #%d", count) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unprocessable entity", + "type": "invalid_request_error", + }) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify 422 is returned to client + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("Expected status 422, got %d", w.Code) + } + + body := w.Body.String() + t.Logf("Response body: %s", body) + + // Verify exactly 1 upstream call (no retry) + actualCalls := upstreamCallCount.Load() + if actualCalls != 1 { + t.Errorf("Expected 1 upstream call (no retry), got %d", actualCalls) + } + + t.Logf("PASS: 422 not retried, %d upstream call", actualCalls) +} + +// TestRetryValidationEmptyJSONBodyRetry tests empty JSON body on 200 triggers retry +// and eventually returns 502 after MAX_RETRIES. +func TestRetryValidationEmptyJSONBodyRetry(t *testing.T) { + maxRetries := 1 // Small for fast test + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 200 with empty body + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + w.WriteHeader(http.StatusOK) + // Empty body - no data written + t.Logf("Upstream call #%d - returning empty body", count) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify 502 is returned to client (empty body after retries) + if w.Code != http.StatusBadGateway { + t.Errorf("Expected status 502, got %d", w.Code) + } + + // Verify upstream call count (1 initial + 1 retry = 2 total with maxRetries=1) + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + t.Logf("PASS: empty body triggered retry, %d upstream calls, returned 502", actualCalls) +} + +// TestRetryValidationInvalidJSONBodyRetry tests invalid JSON body on 200 triggers retry +// and eventually returns 502 after MAX_RETRIES. +func TestRetryValidationInvalidJSONBodyRetry(t *testing.T) { + maxRetries := 1 + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 200 with invalid JSON + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + w.WriteHeader(http.StatusOK) + w.Write([]byte("{invalid json")) + t.Logf("Upstream call #%d - returning invalid JSON", count) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify 502 is returned to client + if w.Code != http.StatusBadGateway { + t.Errorf("Expected status 502, got %d", w.Code) + } + + // Verify upstream call count + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + t.Logf("PASS: invalid JSON triggered retry, %d upstream calls, returned 502", actualCalls) +} + +// TestRetryValidationEmptyStreamingRetry tests empty streaming response +// triggers retry and eventually returns 502 after MAX_RETRIES. +func TestRetryValidationEmptyStreamingRetry(t *testing.T) { + maxRetries := 1 + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 200 with empty streaming response + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + // No data written - empty streaming response + t.Logf("Upstream call #%d - returning empty streaming", count) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify 502 is returned to client + if w.Code != http.StatusBadGateway { + t.Errorf("Expected status 502, got %d", w.Code) + } + + // Verify upstream call count + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + t.Logf("PASS: empty streaming triggered retry, %d upstream calls, returned 502", actualCalls) +} + +// TestRetryValidation500NoRetry tests that 500 responses are NOT retried. +func TestRetryValidation500NoRetry(t *testing.T) { + maxRetries := 5 + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 500 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify 500 is returned to client + if w.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500, got %d", w.Code) + } + + // Verify exactly 1 upstream call (no retry) + actualCalls := upstreamCallCount.Load() + if actualCalls != 1 { + t.Errorf("Expected 1 upstream call (no retry), got %d", actualCalls) + } + + t.Logf("PASS: 500 not retried, %d upstream call", actualCalls) +} + +// TestRetryValidation404NoRetry tests that 404 responses are NOT retried. +func TestRetryValidation404NoRetry(t *testing.T) { + maxRetries := 5 + var upstreamCallCount atomic.Int32 + + // Create upstream that returns 404 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.WriteHeader(http.StatusNotFound) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify 404 is returned to client + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } + + // Verify exactly 1 upstream call (no retry) + actualCalls := upstreamCallCount.Load() + if actualCalls != 1 { + t.Errorf("Expected 1 upstream call (no retry), got %d", actualCalls) + } + + t.Logf("PASS: 404 not retried, %d upstream call", actualCalls) +} + +// TestRetryValidationSuccessNoRetry tests successful request passes through correctly. +func TestRetryValidationSuccessNoRetry(t *testing.T) { + maxRetries := 3 + var upstreamCallCount atomic.Int32 + + // Create upstream that returns success immediately + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_test123", + "type": "message", + "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "Hello!"}}, + "usage": map[string]int{"input_tokens": 10, "output_tokens": 5}, + }) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify success is returned to client + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + body := w.Body.String() + if !json.Valid([]byte(body)) { + t.Error("Response body is not valid JSON") + } + + // Verify exactly 1 upstream call (success, no retry) + actualCalls := upstreamCallCount.Load() + if actualCalls != 1 { + t.Errorf("Expected 1 upstream call (success, no retry), got %d", actualCalls) + } + + t.Logf("PASS: success returned, %d upstream call", actualCalls) +} + +// TestRetryValidationMetrics429 tests that 429 retries increment metrics correctly. +func TestRetryValidationMetrics429(t *testing.T) { + maxRetries := 2 + var upstreamCallCount atomic.Int32 + + // Reset metrics before test + retryAttempts.Reset() + upstreamErrors.Reset() + + // Create upstream that returns 429 twice, then success + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + if count <= 2 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"id": "msg_123"}) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify retry attempts metric - should have 2 retries with reason="429" + retryMetric := retryAttempts.WithLabelValues("429", "test") + retryCount := testutil.ToFloat64(retryMetric) + if retryCount != float64(2) { + t.Errorf("Expected 2 retry attempts, got %f", retryCount) + } + + // Verify total upstream calls + actualCalls := upstreamCallCount.Load() + expectedCalls := int32(3) // 1 initial + 2 retries + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + t.Logf("PASS: 429 metrics verified, %d upstream calls, %f retries", actualCalls, retryCount) +} + +// TestRetryValidationMetrics422 tests that 422 increments error metric but NOT retry metric. +func TestRetryValidationMetrics422(t *testing.T) { + maxRetries := 5 + var upstreamCallCount atomic.Int32 + + // Reset metrics before test + retryAttempts.Reset() + upstreamErrors.Reset() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]string{"error": "unprocessable"}) + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify upstream errors metric - should have 1 error with error_type="422" + errorMetric := upstreamErrors.WithLabelValues("422", "test") + errorCount := testutil.ToFloat64(errorMetric) + if errorCount != 1 { + t.Errorf("Expected 1 upstream error (422), got %f", errorCount) + } + + // Verify NO retry attempts for 422 + retryMetric := retryAttempts.WithLabelValues("422", "test") + retryCount := testutil.ToFloat64(retryMetric) + if retryCount != 0 { + t.Errorf("Expected 0 retry attempts for 422 (no retry), got %f", retryCount) + } + + // Verify exactly 1 upstream call + actualCalls := upstreamCallCount.Load() + if actualCalls != 1 { + t.Errorf("Expected 1 upstream call (no retry), got %d", actualCalls) + } + + t.Logf("PASS: 422 error recorded, no retry attempts, 1 upstream call") +} + +// TestRetryValidationMetricsEmptyBody tests that empty body retries increment metrics correctly. +func TestRetryValidationMetricsEmptyBody(t *testing.T) { + maxRetries := 1 + var upstreamCallCount atomic.Int32 + + // Reset metrics before test + retryAttempts.Reset() + upstreamErrors.Reset() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.WriteHeader(http.StatusOK) + // Empty body + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createNonStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify upstream errors metric - should have 2 errors with error_type="truncated_response" + // (one for each failed attempt before the last one) + errorMetric := upstreamErrors.WithLabelValues("truncated_response", "test") + errorCount := testutil.ToFloat64(errorMetric) + if errorCount != 2 { + t.Errorf("Expected 2 upstream errors (truncated_response), got %f", errorCount) + } + + // Verify retry attempts metric - should have 1 retry with reason="truncated_response" + retryMetric := retryAttempts.WithLabelValues("truncated_response", "test") + retryCount := testutil.ToFloat64(retryMetric) + if retryCount != 1 { + t.Errorf("Expected 1 retry attempt (truncated_response), got %f", retryCount) + } + + // Verify upstream call count (1 initial + 1 retry = 2 total) + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + t.Logf("PASS: truncated_response errors=%f, retries=%f, upstream calls=%d", + errorCount, retryCount, actualCalls) +} + +// TestRetryValidationMetricsEmptyStreaming tests that empty streaming retries increment metrics correctly. +func TestRetryValidationMetricsEmptyStreaming(t *testing.T) { + maxRetries := 1 + var upstreamCallCount atomic.Int32 + + // Reset metrics before test + retryAttempts.Reset() + upstreamErrors.Reset() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCallCount.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + // Empty streaming response + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, maxRetries) + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(createStreamingRequestBody())) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Verify upstream errors metric - should have 2 errors with error_type="empty_streaming" + errorMetric := upstreamErrors.WithLabelValues("empty_streaming", "test") + errorCount := testutil.ToFloat64(errorMetric) + if errorCount != 2 { + t.Errorf("Expected 2 upstream errors (empty_streaming), got %f", errorCount) + } + + // Verify retry attempts metric - should have 1 retry with reason="empty_streaming" + retryMetric := retryAttempts.WithLabelValues("empty_streaming", "test") + retryCount := testutil.ToFloat64(retryMetric) + if retryCount != 1 { + t.Errorf("Expected 1 retry attempt (empty_streaming), got %f", retryCount) + } + + // Verify upstream call count + expectedCalls := int32(maxRetries + 1) + actualCalls := upstreamCallCount.Load() + if actualCalls != expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", expectedCalls, actualCalls) + } + + t.Logf("PASS: empty_streaming errors=%f, retries=%f, upstream calls=%d", + errorCount, retryCount, actualCalls) +} + +// TestRetryValidationErrorClassificationTable tests all rows from the plan.md error classification table. +func TestRetryValidationErrorClassificationTable(t *testing.T) { + testCases := []struct { + name string + maxRetries int + scenario string + expectedCalls int32 // Expected upstream call count + expectedStatusCode int + shouldRetry bool + description string + }{ + { + name: "429_with_retry_after", + maxRetries: 2, + scenario: "429-retry-after-success", + expectedCalls: 3, // 1 initial + 2 retries + expectedStatusCode: 200, + shouldRetry: true, + description: "429 with Retry-After should honor delay and retry", + }, + { + name: "429_no_header", + maxRetries: 1, + scenario: "429-always", + expectedCalls: 2, // 1 initial + 1 retry + expectedStatusCode: 429, + shouldRetry: true, + description: "429 without header should use exponential backoff", + }, + { + name: "422_no_retry", + maxRetries: 5, + scenario: "422-always", + expectedCalls: 1, // No retry + expectedStatusCode: 422, + shouldRetry: false, + description: "422 should not be retried", + }, + { + name: "empty_json_body", + maxRetries: 1, + scenario: "200-empty", + expectedCalls: 2, // 1 initial + 1 retry + expectedStatusCode: 502, + shouldRetry: true, + description: "Empty JSON body should retry, return 502 after max retries", + }, + { + name: "invalid_json_body", + maxRetries: 1, + scenario: "200-invalid-json", + expectedCalls: 2, // 1 initial + 1 retry + expectedStatusCode: 502, + shouldRetry: true, + description: "Invalid JSON body should retry, return 502 after max retries", + }, + { + name: "empty_streaming", + maxRetries: 1, + scenario: "200-empty-streaming", + expectedCalls: 2, // 1 initial + 1 retry + expectedStatusCode: 502, + shouldRetry: true, + description: "Empty streaming should retry, return 502 after max retries", + }, + { + name: "500_no_retry", + maxRetries: 5, + scenario: "500-always", + expectedCalls: 1, // No retry + expectedStatusCode: 500, + shouldRetry: false, + description: "500 should not be retried", + }, + { + name: "404_no_retry", + maxRetries: 5, + scenario: "404-always", + expectedCalls: 1, // No retry + expectedStatusCode: 404, + shouldRetry: false, + description: "404 should not be retried", + }, + { + name: "success", + maxRetries: 3, + scenario: "200-success", + expectedCalls: 1, // Success, no retry + expectedStatusCode: 200, + shouldRetry: false, + description: "Success should not trigger retry", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Log(tc.description) + + var upstreamCallCount atomic.Int32 + + // Create upstream based on scenario + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := upstreamCallCount.Add(1) + switch tc.scenario { + case "429-retry-after-success": + if count <= 2 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"id": "msg_123"}) + } + case "429-always": + w.WriteHeader(http.StatusTooManyRequests) + case "422-always": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]string{"error": "unprocessable"}) + case "200-empty": + w.WriteHeader(http.StatusOK) + // Empty body + case "200-invalid-json": + w.WriteHeader(http.StatusOK) + w.Write([]byte("{invalid json")) + case "200-empty-streaming": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + // Empty streaming + case "500-always": + w.WriteHeader(http.StatusInternalServerError) + case "404-always": + w.WriteHeader(http.StatusNotFound) + case "200-success": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"id": "msg_123"}) + } + })) + defer upstream.Close() + + handler := createTestProxyHandler(t, upstream.URL, tc.maxRetries) + + // Use streaming request body for streaming scenario + var reqBody string + if tc.scenario == "200-empty-streaming" { + reqBody = createStreamingRequestBody() + } else { + reqBody = createNonStreamingRequestBody() + } + + req := httptest.NewRequest("POST", "/v1/messages", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != tc.expectedStatusCode { + t.Errorf("Expected status %d, got %d", tc.expectedStatusCode, w.Code) + } + + actualCalls := upstreamCallCount.Load() + if actualCalls != tc.expectedCalls { + t.Errorf("Expected %d upstream calls, got %d", tc.expectedCalls, actualCalls) + } + + t.Logf("PASS: %d upstream calls, status %d", actualCalls, w.Code) + }) + } +} + +// TestRetryValidationExponentialBackoffDuration tests that exponential backoff delays +// are calculated correctly: 1s, 2s, 4s, 8s, etc. +func TestRetryValidationExponentialBackoffDuration(t *testing.T) { + testCases := []struct { + attempt int + expectedDuration time.Duration + }{ + {1, 1 * time.Second}, + {2, 2 * time.Second}, + {3, 4 * time.Second}, + {4, 8 * time.Second}, + {5, 16 * time.Second}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("attempt_%d", tc.attempt), func(t *testing.T) { + // Calculate backoff: 1 << (attempt - 1) seconds + backoff := time.Duration(1< expectedCalls*2 { + t.Logf("WARNING: Upstream call count outside expected range: got %d, expected ~%d", totalCalls, expectedCalls) + } + + t.Logf("PASS: %d concurrent requests, %d total upstream calls", numConcurrent, totalCalls) +}