Skip to content

Commit 18af85e

Browse files
authored
refactor(util): generalize github retry into sdk/util/retry with typed RateLimitError/AuthError (#88)
Extracts the exponential-backoff engine (WithRetry/WithRetryVoid, context cancellation, Retry-After honoring) out of sdk/integrations/github into a new stdlib-only sdk/util/retry package, alongside typed RateLimitError and AuthError. Error classification stays per-connector: sdk/util/retry only recognizes the typed errors by default (no centralized status-code string matching), and sdk/integrations/github wires in its own isRetryableError/extractRetryAfter classifiers (429/403-rate-limit, 5xx, network errors, GraphQL RATE_LIMITED) via RetryOptions.Classify/ExtractRetryAfter. Behavior-preserving for github; Linear/Jira adoption is a follow-up.
1 parent e4db871 commit 18af85e

3 files changed

Lines changed: 509 additions & 56 deletions

File tree

sdk/integrations/github/retry.go

Lines changed: 18 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,71 +7,33 @@ import (
77
"strconv"
88
"strings"
99
"time"
10+
11+
retryutil "github.com/qf-studio/studio-sdk/sdk/util/retry"
1012
)
1113

12-
// RetryOptions configures retry behavior
13-
type RetryOptions struct {
14-
MaxRetries int // Maximum number of retries (default: 3)
15-
BaseDelay time.Duration // Initial delay between retries (default: 1s)
16-
MaxDelay time.Duration // Maximum delay between retries (default: 30s)
17-
}
14+
// RetryOptions configures retry behavior. The backoff/context-cancellation
15+
// engine lives in sdk/util/retry; this alias keeps the existing github API
16+
// surface (Client.retryOpts, tests) unchanged.
17+
type RetryOptions = retryutil.RetryOptions
1818

19-
// DefaultRetryOptions returns sensible defaults for retry behavior
19+
// DefaultRetryOptions returns sensible defaults for retry behavior.
2020
func DefaultRetryOptions() RetryOptions {
21-
return RetryOptions{
22-
MaxRetries: 3,
23-
BaseDelay: 1 * time.Second,
24-
MaxDelay: 30 * time.Second,
25-
}
21+
return retryutil.DefaultRetryOptions()
2622
}
2723

2824
// WithRetry executes an operation with exponential backoff retry.
29-
// It respects context cancellation and GitHub's Retry-After header.
25+
// It respects context cancellation and GitHub's Retry-After header. Error
26+
// classification defaults to GitHub's own isRetryableError/extractRetryAfter
27+
// (provider-specific: 429/403-rate-limit, 5xx, network errors, GraphQL
28+
// RATE_LIMITED) unless the caller already set Classify/ExtractRetryAfter.
3029
func WithRetry[T any](ctx context.Context, op func() (T, error), opts RetryOptions) (T, error) {
31-
var result T
32-
var lastErr error
33-
34-
for attempt := 0; attempt <= opts.MaxRetries; attempt++ {
35-
result, lastErr = op()
36-
if lastErr == nil {
37-
return result, nil
38-
}
39-
40-
// Don't retry non-retryable errors
41-
if !isRetryableError(lastErr) {
42-
return result, lastErr
43-
}
44-
45-
// Don't retry if we've exhausted retries
46-
if attempt >= opts.MaxRetries {
47-
return result, lastErr
48-
}
49-
50-
// Calculate delay with exponential backoff: 1s, 2s, 4s, 8s...
51-
delay := opts.BaseDelay * time.Duration(1<<uint(attempt))
52-
if delay > opts.MaxDelay {
53-
delay = opts.MaxDelay
54-
}
55-
56-
// Check for Retry-After header in rate limit errors; cap at MaxDelay so
57-
// a runaway "Retry-After: 3600" can't stall the worker for an hour.
58-
if retryAfter := extractRetryAfter(lastErr); retryAfter > 0 {
59-
if retryAfter > opts.MaxDelay {
60-
retryAfter = opts.MaxDelay
61-
}
62-
delay = retryAfter
63-
}
64-
65-
// Wait with context cancellation support
66-
select {
67-
case <-ctx.Done():
68-
return result, ctx.Err()
69-
case <-time.After(delay):
70-
// Continue to next retry attempt
71-
}
30+
if opts.Classify == nil {
31+
opts.Classify = isRetryableError
7232
}
73-
74-
return result, lastErr
33+
if opts.ExtractRetryAfter == nil {
34+
opts.ExtractRetryAfter = extractRetryAfter
35+
}
36+
return retryutil.WithRetry(ctx, op, opts)
7537
}
7638

7739
// WithRetryVoid is like WithRetry but for operations that don't return a value.

sdk/util/retry/retry.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Package retry provides a provider-agnostic retry engine (exponential
2+
// backoff, context cancellation, Retry-After honoring) plus the two typed
3+
// errors every connector needs to drive it: RateLimitError and AuthError.
4+
//
5+
// Error classification is deliberately NOT centralized here. Each connector
6+
// knows how its provider signals "rate limited" or "auth rejected" (status
7+
// codes, headers, GraphQL error types, ...) and maps those onto typed errors
8+
// or its own RetryOptions.Classify/ExtractRetryAfter functions. This package
9+
// only understands the typed RateLimitError/AuthError by default; it does not
10+
// pattern-match provider error strings.
11+
//
12+
// This is a leaf package with no dependencies beyond the Go stdlib, so any
13+
// connector can import it without creating a cycle.
14+
package retry
15+
16+
import (
17+
"context"
18+
"errors"
19+
"fmt"
20+
"time"
21+
)
22+
23+
// RateLimitError signals that an operation was rejected because of API rate
24+
// limiting. RetryAfter carries the provider's requested backoff when known;
25+
// zero means the caller should fall back to exponential backoff.
26+
type RateLimitError struct {
27+
RetryAfter time.Duration
28+
Message string
29+
}
30+
31+
func (e *RateLimitError) Error() string {
32+
return fmt.Sprintf("rate limited: %s", e.Message)
33+
}
34+
35+
// AuthError signals that the credentials themselves were rejected (e.g. HTTP
36+
// 401). It is never retryable — retrying with the same token cannot help, so
37+
// callers should park the operation immediately instead of burning attempts.
38+
type AuthError struct {
39+
Message string
40+
}
41+
42+
func (e *AuthError) Error() string {
43+
return fmt.Sprintf("auth error: %s", e.Message)
44+
}
45+
46+
// RetryOptions configures WithRetry / WithRetryVoid.
47+
//
48+
// Classify and ExtractRetryAfter are per-connector hooks: each integration
49+
// maps its provider's error responses onto typed errors (or its own richer
50+
// error types) and supplies matching functions here. Both default to typed-
51+
// error-only behavior when left nil — no status-code string matching.
52+
type RetryOptions struct {
53+
MaxRetries int // Maximum number of retries (default: 3)
54+
BaseDelay time.Duration // Initial delay between retries (default: 1s)
55+
MaxDelay time.Duration // Maximum delay between retries (default: 30s)
56+
57+
// Classify reports whether err is transient and worth retrying. Defaults
58+
// to DefaultClassify when nil.
59+
Classify func(err error) bool
60+
61+
// ExtractRetryAfter returns the provider-requested backoff for err, or 0
62+
// if none applies. Defaults to DefaultExtractRetryAfter when nil.
63+
ExtractRetryAfter func(err error) time.Duration
64+
}
65+
66+
// DefaultRetryOptions returns sensible defaults for retry behavior.
67+
func DefaultRetryOptions() RetryOptions {
68+
return RetryOptions{
69+
MaxRetries: 3,
70+
BaseDelay: 1 * time.Second,
71+
MaxDelay: 30 * time.Second,
72+
}
73+
}
74+
75+
// DefaultClassify retries *RateLimitError and refuses *AuthError; any other
76+
// error is treated as non-retryable. Connectors with richer transient-error
77+
// detection (5xx, network failures, provider-specific GraphQL errors, ...)
78+
// should supply their own Classify via RetryOptions.
79+
func DefaultClassify(err error) bool {
80+
if err == nil {
81+
return false
82+
}
83+
var rlErr *RateLimitError
84+
if errors.As(err, &rlErr) {
85+
return true
86+
}
87+
var authErr *AuthError
88+
if errors.As(err, &authErr) {
89+
return false
90+
}
91+
return false
92+
}
93+
94+
// DefaultExtractRetryAfter returns RateLimitError.RetryAfter when err (or
95+
// something it wraps) is a *RateLimitError, else 0.
96+
func DefaultExtractRetryAfter(err error) time.Duration {
97+
var rlErr *RateLimitError
98+
if errors.As(err, &rlErr) {
99+
return rlErr.RetryAfter
100+
}
101+
return 0
102+
}
103+
104+
// WithRetry executes op with exponential backoff, honoring context
105+
// cancellation and any provider-requested Retry-After delay reported via
106+
// opts.ExtractRetryAfter.
107+
func WithRetry[T any](ctx context.Context, op func() (T, error), opts RetryOptions) (T, error) {
108+
classify := opts.Classify
109+
if classify == nil {
110+
classify = DefaultClassify
111+
}
112+
extractRetryAfter := opts.ExtractRetryAfter
113+
if extractRetryAfter == nil {
114+
extractRetryAfter = DefaultExtractRetryAfter
115+
}
116+
117+
var result T
118+
var lastErr error
119+
120+
for attempt := 0; attempt <= opts.MaxRetries; attempt++ {
121+
result, lastErr = op()
122+
if lastErr == nil {
123+
return result, nil
124+
}
125+
126+
if !classify(lastErr) {
127+
return result, lastErr
128+
}
129+
if attempt >= opts.MaxRetries {
130+
return result, lastErr
131+
}
132+
133+
// Exponential backoff: BaseDelay, 2x, 4x, 8x... capped at MaxDelay.
134+
delay := opts.BaseDelay * time.Duration(1<<uint(attempt))
135+
if delay > opts.MaxDelay {
136+
delay = opts.MaxDelay
137+
}
138+
139+
// A provider-requested Retry-After overrides backoff, but is still
140+
// capped at MaxDelay so a runaway "Retry-After: 3600" can't stall
141+
// the caller for an hour.
142+
if retryAfter := extractRetryAfter(lastErr); retryAfter > 0 {
143+
if retryAfter > opts.MaxDelay {
144+
retryAfter = opts.MaxDelay
145+
}
146+
delay = retryAfter
147+
}
148+
149+
select {
150+
case <-ctx.Done():
151+
return result, ctx.Err()
152+
case <-time.After(delay):
153+
}
154+
}
155+
156+
return result, lastErr
157+
}
158+
159+
// WithRetryVoid is like WithRetry but for operations that don't return a value.
160+
func WithRetryVoid(ctx context.Context, op func() error, opts RetryOptions) error {
161+
_, err := WithRetry(ctx, func() (struct{}, error) {
162+
return struct{}{}, op()
163+
}, opts)
164+
return err
165+
}

0 commit comments

Comments
 (0)