From 9469724a98a065284eb883c2b15db90bf2973534 Mon Sep 17 00:00:00 2001 From: Christian Gonzalez Di Antonio Date: Sat, 7 Mar 2026 20:47:53 +0100 Subject: [PATCH] fix: deadline and timeout with retry --- http_retrier.go | 10 +++++++++ http_retrier_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/http_retrier.go b/http_retrier.go index 6504c59..3fad81b 100644 --- a/http_retrier.go +++ b/http_retrier.go @@ -126,6 +126,16 @@ func (r *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { } } + // Do not retry if the error is due to context cancellation or deadline exceeded. + // When http.Client.Timeout fires, it cancels the request context. Since this + // context is shared across all retry attempts, subsequent retries would fail + // immediately. Return the original error to avoid misleading "retry cancelled" messages. + if err != nil { + if ctx := req.Context(); ctx != nil && ctx.Err() != nil { + return nil, err + } + } + // Check if we should retry if attempt < r.MaxRetries { delay := retryStrategy(attempt) diff --git a/http_retrier_test.go b/http_retrier_test.go index d60138b..52ff2d4 100644 --- a/http_retrier_test.go +++ b/http_retrier_test.go @@ -1047,6 +1047,56 @@ func TestRetryTransport_ContextCancellation(t *testing.T) { } } +func TestRetryTransport_NoRetryOnContextDeadlineExceeded(t *testing.T) { + var attempts int32 = 0 + + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + // Simulate the error that http.Client produces when Client.Timeout fires + return nil, fmt.Errorf("Post \"http://localhost:11434/api/generate\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)") + }, + } + + retryRT := &retryTransport{ + Transport: mockRT, + MaxRetries: 5, + RetryStrategy: FixedDelay(1 * time.Millisecond), + } + + // Create a request with an already-expired context to simulate Client.Timeout behavior + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately to simulate expired deadline + + req, err := http.NewRequestWithContext(ctx, "POST", "http://localhost:11434/api/generate", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + start := time.Now() + _, err = retryRT.RoundTrip(req) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("Expected an error, got nil") + } + + // Should have made exactly 1 attempt — no retries when context is done + if atomic.LoadInt32(&attempts) != 1 { + t.Errorf("Expected exactly 1 attempt (no retries on context deadline), got %d", atomic.LoadInt32(&attempts)) + } + + // Should return quickly without waiting for retry delays + if elapsed > 500*time.Millisecond { + t.Errorf("Expected immediate return, but took %v", elapsed) + } + + // The error should be the original transport error, not wrapped as "retry cancelled" + if strings.Contains(err.Error(), "retry cancelled") { + t.Errorf("Error should not contain 'retry cancelled', got: %v", err) + } +} + func TestRetryTransport_ContextCancelledDuringRetryDelay(t *testing.T) { var attempts int32 = 0