Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
12 changes: 10 additions & 2 deletions generators/go-v2/base/src/asIs/internal/retrier.go_
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,21 @@ func (r *Retrier) run(
}

if r.shouldRetry(response) {
defer func() { _ = response.Body.Close() }()

delay, err := r.retryDelay(response, retryAttempt)
if err != nil {
_ = response.Body.Close()
return nil, err
}

// If the context's deadline would elapse before the backoff completes, return
// the response instead of sleeping through it. The caller then sees why the
// request actually failed (e.g. a 429) rather than a context deadline error.
if deadline, ok := request.Context().Deadline(); ok && time.Until(deadline) < delay {
return response, nil
}

defer func() { _ = response.Body.Close() }()

if err := sleepWithContext(request.Context(), delay); err != nil {
return nil, err
}
Expand Down
90 changes: 87 additions & 3 deletions generators/go-v2/base/src/asIs/internal/retrier_test.go_
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ func TestRetryWithRequestBody(t *testing.T) {
assert.Equal(t, expectedBody, requestBodies[1], "Second request body should match expected (retry should re-send body)")
}

// An explicit cancel (e.g. Ctrl-C wired to cancel()) must interrupt the backoff
// wait. There is no deadline here, so the retrier cannot know the wait is futile
// up front -- it has to be woken by the context.
func TestRetryWaitIsInterruptedByContext(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -308,7 +311,11 @@ func TestRetryWaitIsInterruptedByContext(t *testing.T) {
Client: server.Client(),
})

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
defer cancel()

start := time.Now()
Expand All @@ -323,11 +330,88 @@ func TestRetryWaitIsInterruptedByContext(t *testing.T) {
)
elapsed := time.Since(start)

assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Equal(t, 1, requestCount, "Expected the retry to be abandoned once the context expired")
assert.ErrorIs(t, err, context.Canceled)
assert.Equal(t, 1, requestCount, "Expected the retry to be abandoned once the context was cancelled")
assert.Less(t, elapsed, time.Second, "Expected the backoff to be interrupted by the context, took %v", elapsed)
}

// When the caller's deadline would elapse before the backoff finishes, sleeping
// only guarantees a context error. Return the response instead so the caller
// learns why the request actually failed.
func TestRetryReturnsResponseWhenDeadlineShorterThanBackoff(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Retry-After", "5")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer server.Close()

caller := NewCaller(&CallerParams{
Client: server.Client(),
})

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

start := time.Now()
_, err := caller.Call(
ctx,
&CallParams{
URL: server.URL,
Method: http.MethodGet,
Request: &InternalTestRequest{},
MaxAttempts: 3,
},
)
elapsed := time.Since(start)

// The caller gets the 429, not context.DeadlineExceeded.
var apiError *core.APIError
require.ErrorAs(t, err, &apiError)
assert.Equal(t, http.StatusTooManyRequests, apiError.StatusCode)
assert.NotErrorIs(t, err, context.DeadlineExceeded)
assert.Equal(t, 1, requestCount, "Expected no retry once the deadline was known to be too short")
assert.Less(t, elapsed, time.Second, "Expected to return immediately rather than sleep, took %v", elapsed)
}

// A deadline with room for the backoff must still retry as normal.
func TestRetryProceedsWhenDeadlineLongerThanBackoff(t *testing.T) {
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if requestCount == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{}"))
}))
defer server.Close()

caller := NewCaller(&CallerParams{
Client: server.Client(),
})

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

_, err := caller.Call(
ctx,
&CallParams{
URL: server.URL,
Method: http.MethodGet,
Request: &InternalTestRequest{},
Response: &InternalTestResponse{},
MaxAttempts: 3,
},
)

assert.NoError(t, err)
assert.Equal(t, 2, requestCount, "Expected the retry to proceed when the deadline allows it")
}

func TestDisableRetries(t *testing.T) {
tests := []struct {
name string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
When a request's context deadline would elapse before the retry backoff
completes, the retrier now returns the response instead of sleeping through
the remaining deadline. Callers see why the request actually failed (for
example a `429` with its `Retry-After` and body) rather than a
`context deadline exceeded` error. An explicit `cancel()` during the
backoff still returns the context's error.
type: fix
12 changes: 10 additions & 2 deletions seed/go-sdk/accept-header/internal/retrier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 87 additions & 3 deletions seed/go-sdk/accept-header/internal/retrier_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions seed/go-sdk/alias-extends/internal/retrier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading