Skip to content

Commit 24efa60

Browse files
committed
feat: add retry timout
Signed-off-by: Eray Ates <eray.ates@worldline.com>
1 parent f6bf1e9 commit 24efa60

5 files changed

Lines changed: 265 additions & 2 deletions

File tree

client.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package klient
33
import (
44
"context"
55
"crypto/tls"
6+
"errors"
67
"fmt"
78
"net"
89
"net/http"
@@ -132,9 +133,23 @@ func New(opts ...OptionClientFn) (*Client, error) {
132133
Transport: &http2.Transport{
133134
AllowHTTP: true,
134135
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
135-
return net.Dial(network, addr)
136+
var dialer net.Dialer
137+
138+
conn, err := dialer.DialContext(ctx, network, addr)
139+
if err != nil {
140+
return nil, err
141+
}
142+
// If TLS config is provided, wrap the connection
143+
if cfg != nil {
144+
return tls.Client(conn, cfg), nil
145+
}
146+
147+
return conn, nil
136148
},
137-
IdleConnTimeout: 90 * time.Second,
149+
IdleConnTimeout: 90 * time.Second,
150+
ReadIdleTimeout: 30 * time.Second,
151+
PingTimeout: 15 * time.Second,
152+
StrictMaxConcurrentStreams: true,
138153
},
139154
}
140155
case o.PooledClient:
@@ -225,6 +240,16 @@ func New(opts ...OptionClientFn) (*Client, error) {
225240
}
226241

227242
if !o.DisableRetry {
243+
// Wrap the transport with retry timeout BEFORE creating the retry client
244+
// This ensures each attempt gets its own timeout
245+
if o.RetryTimeout > 0 {
246+
baseTransport := client.Transport
247+
client.Transport = &retryTimeoutTransport{
248+
base: baseTransport,
249+
timeout: o.RetryTimeout,
250+
}
251+
}
252+
228253
// create retry client
229254
retryClient := retryablehttp.Client{
230255
HTTPClient: client,
@@ -237,6 +262,25 @@ func New(opts ...OptionClientFn) (*Client, error) {
237262
ErrorHandler: PassthroughErrorHandler,
238263
}
239264

265+
// If RetryTimeout is set, wrap the check retry to allow retry on timeout
266+
if o.RetryTimeout > 0 {
267+
originalCheckRetry := retryClient.CheckRetry
268+
retryClient.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
269+
// If there's a context deadline exceeded error but the parent context is still valid,
270+
// it means the timeout came from our per-request timeout, so we should retry
271+
if err != nil && ctx.Err() == nil {
272+
// Check if the error is a timeout/deadline error
273+
if isTimeoutError(err) {
274+
// Allow retry on timeout
275+
return true, nil
276+
}
277+
}
278+
279+
// Otherwise use the original retry policy
280+
return originalCheckRetry(ctx, resp, err)
281+
}
282+
}
283+
240284
client = retryClient.StandardClient()
241285
}
242286

@@ -282,3 +326,23 @@ func New(opts ...OptionClientFn) (*Client, error) {
282326
HTTP: client,
283327
}, nil
284328
}
329+
330+
// isTimeoutError checks if an error is a timeout or deadline exceeded error.
331+
func isTimeoutError(err error) bool {
332+
if err == nil {
333+
return false
334+
}
335+
336+
// Check for context deadline exceeded
337+
if errors.Is(err, context.DeadlineExceeded) {
338+
return true
339+
}
340+
341+
// Check for net.Error with Timeout() == true
342+
var netErr net.Error
343+
if errors.As(err, &netErr) && netErr.Timeout() {
344+
return true
345+
}
346+
347+
return false
348+
}

config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ type Config struct {
1616
RetryMax int `cfg:"retry_max"`
1717
RetryWaitMin time.Duration `cfg:"retry_wait_min"`
1818
RetryWaitMax time.Duration `cfg:"retry_wait_max"`
19+
RetryTimeout time.Duration `cfg:"retry_timeout"`
20+
21+
PooledClient *bool `cfg:"pooled_client"`
1922

2023
Proxy string `cfg:"proxy"`
2124
HTTP2 *bool `cfg:"http2"`
@@ -61,6 +64,14 @@ func (c Config) ToOption() OptionClientFn {
6164
o.RetryWaitMax = c.RetryWaitMax
6265
}
6366

67+
if c.RetryTimeout != 0 {
68+
o.RetryTimeout = c.RetryTimeout
69+
}
70+
71+
if c.PooledClient != nil {
72+
o.PooledClient = *c.PooledClient
73+
}
74+
6475
if len(c.Header) > 0 {
6576
o.Header = c.Header
6677
}

example_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"fmt"
88
"net/http"
99
"net/http/httptest"
10+
"sync/atomic"
11+
"testing"
12+
"time"
1013

1114
"github.com/worldline-go/klient"
1215
)
@@ -123,3 +126,141 @@ func Example() {
123126
// Output:
124127
// 123+
125128
}
129+
130+
func ExampleWithRetryTimeout() {
131+
// Create a client with retry timeout of 2 seconds per attempt
132+
client, err := klient.New(
133+
klient.WithDisableBaseURLCheck(true),
134+
klient.WithRetryMax(3), // Will retry up to 3 times
135+
klient.WithRetryWaitMin(500*time.Millisecond),
136+
klient.WithRetryWaitMax(1*time.Second),
137+
klient.WithRetryTimeout(2*time.Second), // Each attempt times out after 2 seconds
138+
)
139+
if err != nil {
140+
panic(err)
141+
}
142+
143+
// If a request takes longer than 2 seconds, it will timeout and retry
144+
// Total possible time: ~2s (first attempt) + 0.5s (wait) + 2s (retry) + ...
145+
_ = client
146+
fmt.Println("Client created with 2 second timeout per retry attempt")
147+
// Output: Client created with 2 second timeout per retry attempt
148+
}
149+
150+
func TestRetryTimeout(t *testing.T) {
151+
var attemptCount atomic.Int32
152+
153+
// Create a server that delays responses
154+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+
attempt := attemptCount.Add(1)
156+
t.Logf("Attempt %d received", attempt)
157+
158+
// First 2 attempts take too long (will timeout)
159+
// Third attempt is fast (will succeed)
160+
if attempt < 3 {
161+
time.Sleep(3 * time.Second) // Longer than RetryTimeout
162+
}
163+
164+
w.WriteHeader(http.StatusOK)
165+
w.Write([]byte("success"))
166+
}))
167+
defer server.Close()
168+
169+
// Create client with retry timeout
170+
client, err := klient.New(
171+
klient.WithDisableBaseURLCheck(true),
172+
klient.WithRetryMax(3),
173+
klient.WithRetryWaitMin(100*time.Millisecond),
174+
klient.WithRetryWaitMax(200*time.Millisecond),
175+
klient.WithRetryTimeout(2*time.Second), // Each attempt times out after 2 seconds
176+
)
177+
if err != nil {
178+
t.Fatal(err)
179+
}
180+
181+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
182+
if err != nil {
183+
t.Fatal(err)
184+
}
185+
186+
start := time.Now()
187+
resp, err := client.HTTP.Do(req)
188+
elapsed := time.Since(start)
189+
190+
if err != nil {
191+
t.Fatalf("Request failed after %v: %v", elapsed, err)
192+
}
193+
defer resp.Body.Close()
194+
195+
attempts := attemptCount.Load()
196+
t.Logf("Request completed in %v with %d attempts", elapsed, attempts)
197+
198+
// Should have made 3 attempts (2 timeouts + 1 success)
199+
if attempts != 3 {
200+
t.Errorf("Expected 3 attempts, got %d", attempts)
201+
}
202+
203+
// Total time should be approximately:
204+
// 2s (timeout) + 0.1-0.2s (wait) + 2s (timeout) + 0.1-0.2s (wait) + <1s (success)
205+
// = approximately 4-5 seconds
206+
if elapsed < 4*time.Second || elapsed > 6*time.Second {
207+
t.Logf("Warning: elapsed time %v outside expected range (4-6s)", elapsed)
208+
}
209+
}
210+
211+
func TestRetryTimeoutAllAttemptsTimeout(t *testing.T) {
212+
var attemptCount atomic.Int32
213+
214+
// Create a server that always delays
215+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216+
attempt := attemptCount.Add(1)
217+
t.Logf("Attempt %d received", attempt)
218+
time.Sleep(5 * time.Second) // Always too slow
219+
w.WriteHeader(http.StatusOK)
220+
}))
221+
defer server.Close()
222+
223+
client, err := klient.New(
224+
klient.WithDisableBaseURLCheck(true),
225+
klient.WithRetryMax(2), // Will try 3 times total (initial + 2 retries)
226+
klient.WithRetryWaitMin(100*time.Millisecond),
227+
klient.WithRetryWaitMax(200*time.Millisecond),
228+
klient.WithRetryTimeout(1*time.Second), // Each attempt times out after 1 second
229+
)
230+
if err != nil {
231+
t.Fatal(err)
232+
}
233+
234+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
235+
if err != nil {
236+
t.Fatal(err)
237+
}
238+
239+
start := time.Now()
240+
resp, err := client.HTTP.Do(req)
241+
elapsed := time.Since(start)
242+
243+
if resp != nil {
244+
resp.Body.Close()
245+
}
246+
247+
attempts := attemptCount.Load()
248+
t.Logf("Request failed after %v with %d attempts (error: %v)", elapsed, attempts, err)
249+
250+
// Should have made 3 attempts (all timeouts)
251+
if attempts != 3 {
252+
t.Errorf("Expected 3 attempts, got %d", attempts)
253+
}
254+
255+
// Should have an error (context deadline exceeded)
256+
if err == nil {
257+
t.Error("Expected error due to timeouts, got nil")
258+
}
259+
260+
// Total time should be approximately:
261+
// 1s (timeout) + 0.1-0.2s (wait) + 1s (timeout) + 0.1-0.2s (wait) + 1s (timeout)
262+
// = approximately 3-4 seconds
263+
if elapsed < 3*time.Second || elapsed > 4*time.Second {
264+
t.Logf("Warning: elapsed time %v outside expected range (3-4s)", elapsed)
265+
}
266+
}

option.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ type optionClientValue struct {
6060
Backoff retryablehttp.Backoff
6161
// RetryLog is the flag to enable retry log of the http body. Default is true.
6262
RetryLog bool
63+
// RetryTimeout is the timeout for each individual retry attempt.
64+
// If a single request attempt exceeds this duration, it will be canceled
65+
// and the retry logic will attempt the request again (up to RetryMax times).
66+
RetryTimeout time.Duration
6367
// OptionRetryFns is the retry options for default retry policy.
6468
OptionRetryFns []OptionRetryFn
6569
// DisableEnvValues is the flag to disable all env values check.
@@ -245,6 +249,18 @@ func WithRetryPolicy(retryPolicy retryablehttp.CheckRetry) OptionClientFn {
245249
}
246250
}
247251

252+
// WithRetryTimeout sets the timeout for each individual retry attempt.
253+
// If a single request takes longer than this duration, it will be canceled
254+
// and trigger a retry (up to RetryMax times).
255+
//
256+
// Example: WithRetryTimeout(2*time.Second) means each attempt will timeout
257+
// after 2 seconds, and the request will be retried if it hasn't succeeded yet.
258+
func WithRetryTimeout(retryTimeout time.Duration) OptionClientFn {
259+
return func(options *optionClientValue) {
260+
options.RetryTimeout = retryTimeout
261+
}
262+
}
263+
248264
// WithRetryLog configures the client to use the provided retry log flag, default is true.
249265
//
250266
// This option is only used with default retry policy.

transport.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package klient
22

33
import (
44
"context"
5+
"fmt"
56
"maps"
67
"net/http"
78
"net/url"
9+
"time"
810
)
911

1012
// TransportKlient is an http.RoundTripper that
@@ -91,3 +93,32 @@ func cloneRequest(r *http.Request) *http.Request {
9193

9294
return r2
9395
}
96+
97+
// retryTimeoutTransport wraps an http.RoundTripper to add a timeout to each request attempt.
98+
// This is used to implement per-attempt timeouts for retry logic.
99+
type retryTimeoutTransport struct {
100+
base http.RoundTripper
101+
timeout time.Duration
102+
}
103+
104+
var _ http.RoundTripper = (*retryTimeoutTransport)(nil)
105+
106+
// RoundTrip implements http.RoundTripper and adds a timeout context to each request.
107+
func (t *retryTimeoutTransport) RoundTrip(req *http.Request) (*http.Response, error) {
108+
// Create a timeout context for this specific attempt
109+
ctx, cancel := context.WithTimeout(req.Context(), t.timeout)
110+
defer cancel()
111+
112+
// Clone the request with the timeout context
113+
req2 := req.Clone(ctx)
114+
115+
resp, err := t.base.RoundTrip(req2)
116+
if err != nil {
117+
if req.Context().Err() == nil && isTimeoutError(err) {
118+
// If the parent context is still valid, return a context deadline exceeded error
119+
return resp, fmt.Errorf("retry timeout; %w", err)
120+
}
121+
}
122+
123+
return resp, err
124+
}

0 commit comments

Comments
 (0)