Skip to content

Commit e06f6d3

Browse files
aeneasrory-bot
authored andcommitted
fix: honor retry.max_delay as outbound request timeout
GitOrigin-RevId: 9f3419b5d328caf2385bc4d71ac33f3b0094cd1c
1 parent 4ce4f76 commit e06f6d3

8 files changed

Lines changed: 200 additions & 33 deletions

pipeline/authn/authenticator_oauth2_client_credentials.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (a *AuthenticatorOAuth2ClientCredentials) Config(config json.RawMessage) (*
9393
if err != nil {
9494
return nil, err
9595
}
96-
timeout := time.Millisecond * duration
96+
timeout := duration
9797
a.client = httpx.NewResilientClient(
9898
httpx.ResilientClientWithMaxRetryWait(maxWait),
9999
httpx.ResilientClientWithConnectionTimeout(timeout),
@@ -220,7 +220,7 @@ func (a *AuthenticatorOAuth2ClientCredentials) Authenticate(r *http.Request, ses
220220
t, err := c.Token(context.WithValue(
221221
r.Context(),
222222
oauth2.HTTPClient,
223-
c.Client,
223+
a.client,
224224
))
225225
if err != nil {
226226
if rErr, ok := err.(*oauth2.RetrieveError); ok {

pipeline/authn/authenticator_oauth2_client_credentials_test.go

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,10 @@ func TestAuthenticatorOAuth2ClientCredentials(t *testing.T) {
269269
},
270270
},
271271
{
272-
d: "fails and returns 503 Service Unavailable error due to the unavailability of the upstream service",
272+
d: "fails and returns not available after the resilient client retries a persistent 503",
273273
r: upstreamFailure,
274274
expectErr: helper.ErrUpstreamServiceNotAvailable(),
275-
config: json.RawMessage(`{}`),
275+
config: json.RawMessage(`{"retry":{"give_up_after":"10ms"}}`),
276276
token_url: "",
277277
setup: func(t *testing.T, h *http.ServeMux, _ json.RawMessage) {
278278
h.HandleFunc("POST /oauth2/token", func(w http.ResponseWriter, r *http.Request) {
@@ -282,10 +282,13 @@ func TestAuthenticatorOAuth2ClientCredentials(t *testing.T) {
282282
},
283283
},
284284
{
285-
d: "fails and returns 504 Gateway Timeout error due to upstream service timeout",
285+
// The resilient client retries the 504 and, once retries are
286+
// exhausted, surfaces the upstream as not available (the specific
287+
// status is no longer available after give-up).
288+
d: "fails and returns not available after the resilient client retries a persistent 504",
286289
r: upstreamFailure,
287-
expectErr: helper.ErrUpstreamServiceTimeout(),
288-
config: json.RawMessage(`{}`),
290+
expectErr: helper.ErrUpstreamServiceNotAvailable(),
291+
config: json.RawMessage(`{"retry":{"give_up_after":"10ms"}}`),
289292
token_url: "",
290293
setup: func(t *testing.T, h *http.ServeMux, _ json.RawMessage) {
291294
h.HandleFunc("POST /oauth2/token", func(w http.ResponseWriter, r *http.Request) {
@@ -295,10 +298,12 @@ func TestAuthenticatorOAuth2ClientCredentials(t *testing.T) {
295298
},
296299
},
297300
{
298-
d: "fails and returns 500 Internal Server Error error due to an unexpected error in the upstream service",
301+
// The resilient client retries the 500 and, once retries are
302+
// exhausted, surfaces the upstream as not available.
303+
d: "fails and returns not available after the resilient client retries a persistent 500",
299304
r: upstreamFailure,
300-
expectErr: helper.ErrUpstreamServiceInternalServerError(),
301-
config: json.RawMessage(`{}`),
305+
expectErr: helper.ErrUpstreamServiceNotAvailable(),
306+
config: json.RawMessage(`{"retry":{"give_up_after":"10ms"}}`),
302307
token_url: "",
303308
setup: func(t *testing.T, h *http.ServeMux, _ json.RawMessage) {
304309
h.HandleFunc("POST /oauth2/token", func(w http.ResponseWriter, r *http.Request) {
@@ -398,3 +403,45 @@ func TestAuthenticatorOAuth2ClientCredentials(t *testing.T) {
398403
require.NoError(t, a.Validate(json.RawMessage(`{"token_url":"`+ts.URL+"/oauth2/token"+`","retry":{"give_up_after":"3s", "max_delay":"100ms"}}`)))
399404
})
400405
}
406+
407+
// TestAuthenticatorOAuth2ClientCredentialsHonorsMaxDelayTimeout is a regression
408+
// test for two coupled bugs that stopped retry.max_delay from being enforced as
409+
// the outbound HTTP timeout for the token request:
410+
//
411+
// - The parsed max_delay duration was multiplied by an extra factor of
412+
// time.Millisecond, inflating the timeout by 1e6 (a 50ms setting became
413+
// ~13.9 hours).
414+
// - The resilient client that carries the timeout (a.client) was never passed
415+
// to the OAuth2 token exchange; a method value (c.Client) was passed
416+
// instead, which the OAuth2 library ignores, so the default client with no
417+
// timeout was used.
418+
//
419+
// With either bug present, a call to a slow token endpoint blocks until the
420+
// server responds; with both fixed, it times out promptly.
421+
func TestAuthenticatorOAuth2ClientCredentialsHonorsMaxDelayTimeout(t *testing.T) {
422+
t.Parallel()
423+
reg := internal.NewRegistry(t, configx.SkipValidation())
424+
a, err := reg.PipelineAuthenticator("oauth2_client_credentials")
425+
require.NoError(t, err)
426+
427+
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
428+
time.Sleep(2 * time.Second)
429+
w.Header().Set("Content-Type", "application/json")
430+
_, _ = w.Write([]byte(`{"access_token":"foo","token_type":"bearer"}`))
431+
}))
432+
t.Cleanup(slow.Close)
433+
434+
r := &http.Request{Header: http.Header{}}
435+
r.SetBasicAuth("client", "secret")
436+
config, err := sjson.SetBytes(
437+
[]byte(`{"retry":{"max_delay":"50ms","give_up_after":"10ms"}}`), "token_url", slow.URL+"/oauth2/token")
438+
require.NoError(t, err)
439+
440+
start := time.Now()
441+
err = a.Authenticate(r, new(authn.AuthenticationSession), config, nil)
442+
elapsed := time.Since(start)
443+
444+
require.Error(t, err)
445+
assert.Less(t, elapsed, time.Second,
446+
"the configured 50ms max_delay must time the token request out; the inflated value would block ~2s on the slow server")
447+
}

pipeline/authn/authenticator_oauth2_introspection.go

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/pkg/errors"
2020
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
2121
"go.opentelemetry.io/otel/trace"
22+
"golang.org/x/oauth2"
2223
"golang.org/x/oauth2/clientcredentials"
2324

2425
"github.com/ory/fosite"
@@ -321,22 +322,6 @@ func (a *AuthenticatorOAuth2Introspection) Config(config json.RawMessage) (*Auth
321322

322323
if !ok || client == nil {
323324
a.d.Logger().Debug("Initializing http client")
324-
var rt http.RoundTripper
325-
if c.PreAuth != nil && c.PreAuth.Enabled {
326-
var ep url.Values
327-
328-
if c.PreAuth.Audience != "" {
329-
ep = url.Values{"audience": {c.PreAuth.Audience}}
330-
}
331-
332-
rt = (&clientcredentials.Config{
333-
ClientID: c.PreAuth.ClientID,
334-
ClientSecret: c.PreAuth.ClientSecret,
335-
Scopes: c.PreAuth.Scope,
336-
EndpointParams: ep,
337-
TokenURL: c.PreAuth.TokenURL,
338-
}).Client(context.Background()).Transport
339-
}
340325

341326
if c.Retry == nil {
342327
c.Retry = &AuthenticatorOAuth2IntrospectionRetryConfiguration{Timeout: "500ms", MaxWait: "1s"}
@@ -348,22 +333,49 @@ func (a *AuthenticatorOAuth2Introspection) Config(config json.RawMessage) (*Auth
348333
c.Retry.MaxWait = "1s"
349334
}
350335
}
351-
duration, err := time.ParseDuration(c.Retry.Timeout)
336+
timeout, err := time.ParseDuration(c.Retry.Timeout)
352337
if err != nil {
353338
return nil, nil, errors.WithStack(err)
354339
}
355-
timeout := time.Millisecond * duration
356-
357340
maxWait, err := time.ParseDuration(c.Retry.MaxWait)
358341
if err != nil {
359342
return nil, nil, errors.WithStack(err)
360343
}
361344

362-
client = httpx.NewResilientClient(
345+
// The resilient client enforces the configured retry.max_delay as the
346+
// per-request connection timeout and retries transient failures. Requests
347+
// must flow through it so those settings are actually honored.
348+
resilient := httpx.NewResilientClient(
363349
httpx.ResilientClientWithMaxRetryWait(maxWait),
364350
httpx.ResilientClientWithConnectionTimeout(timeout),
365351
).StandardClient()
366-
client.Transport = otelhttp.NewTransport(rt, otelhttp.WithTracerProvider(a.d.Tracer(context.Background()).Provider()))
352+
353+
rt := resilient.Transport
354+
if c.PreAuth != nil && c.PreAuth.Enabled {
355+
var ep url.Values
356+
357+
if c.PreAuth.Audience != "" {
358+
ep = url.Values{"audience": {c.PreAuth.Audience}}
359+
}
360+
361+
// Perform the pre-authorization token exchange through the resilient
362+
// client (via the oauth2.HTTPClient context value), and route the
363+
// pre-authorized introspection requests through it as well by making
364+
// the OAuth2 transport wrap the resilient transport.
365+
preAuthCtx := context.WithValue(context.Background(), oauth2.HTTPClient, resilient)
366+
rt = (&clientcredentials.Config{
367+
ClientID: c.PreAuth.ClientID,
368+
ClientSecret: c.PreAuth.ClientSecret,
369+
Scopes: c.PreAuth.Scope,
370+
EndpointParams: ep,
371+
TokenURL: c.PreAuth.TokenURL,
372+
}).Client(preAuthCtx).Transport
373+
}
374+
375+
// Use a fresh outer client for tracing so the OAuth2 token source keeps
376+
// using the resilient client above without recursing through the tracing
377+
// transport.
378+
client = &http.Client{Transport: otelhttp.NewTransport(rt, otelhttp.WithTracerProvider(a.d.Tracer(context.Background()).Provider()))}
367379
a.mu.Lock()
368380
a.clientMap[clientKey] = client
369381
a.mu.Unlock()

pipeline/authn/authenticator_oauth2_introspection_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,3 +898,43 @@ func TestAudienceUnmarshal(t *testing.T) {
898898
})
899899
}
900900
}
901+
902+
// TestAuthenticatorOAuth2IntrospectionRetryTimeout is a regression test for two
903+
// coupled bugs that stopped retry.max_delay from being enforced as the outbound
904+
// HTTP timeout for the introspection request:
905+
//
906+
// - The parsed max_delay duration was multiplied by an extra factor of
907+
// time.Millisecond, inflating the timeout by 1e6 (a 50ms setting became
908+
// ~13.9 hours).
909+
// - The resilient client that carries the timeout was discarded: its transport
910+
// was overwritten with a tracing transport that wrapped a nil round tripper,
911+
// so requests bypassed the timeout and retries entirely.
912+
//
913+
// With either bug present, a call to a slow introspection endpoint blocks until
914+
// the server responds; with both fixed, it times out promptly.
915+
func TestAuthenticatorOAuth2IntrospectionRetryTimeout(t *testing.T) {
916+
t.Parallel()
917+
reg := internal.NewRegistry(t, configx.SkipValidation())
918+
a := NewAuthenticatorOAuth2Introspection(reg)
919+
920+
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
921+
time.Sleep(2 * time.Second)
922+
w.Header().Set("Content-Type", "application/json")
923+
_, _ = w.Write([]byte(`{"active":true}`))
924+
}))
925+
t.Cleanup(slow.Close)
926+
927+
config, err := sjson.SetBytes(
928+
[]byte(`{"scope_strategy":"none","retry":{"max_delay":"50ms","give_up_after":"10ms"}}`),
929+
"introspection_url", slow.URL+"/oauth2/introspect")
930+
require.NoError(t, err)
931+
932+
r := &http.Request{Header: http.Header{"Authorization": {"bearer token"}}}
933+
start := time.Now()
934+
err = a.Authenticate(r, new(AuthenticationSession), config, nil)
935+
elapsed := time.Since(start)
936+
937+
require.Error(t, err)
938+
assert.Less(t, elapsed, time.Second,
939+
"the configured 50ms max_delay must time the request out; the inflated value would block ~2s on the slow server")
940+
}

pipeline/authz/remote.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func (a *AuthorizerRemote) Config(config json.RawMessage) (*AuthorizerRemoteConf
175175
if err != nil {
176176
return nil, err
177177
}
178-
timeout := time.Millisecond * duration
178+
timeout := duration
179179
client := httpx.NewResilientClient(
180180
httpx.ResilientClientWithMaxRetryWait(maxWait),
181181
httpx.ResilientClientWithConnectionTimeout(timeout),

pipeline/authz/remote_json.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func (a *AuthorizerRemoteJSON) Config(config json.RawMessage) (*AuthorizerRemote
186186
if err != nil {
187187
return nil, err
188188
}
189-
timeout := time.Millisecond * duration
189+
timeout := duration
190190
client := httpx.NewResilientClient(
191191
httpx.ResilientClientWithMaxRetryWait(maxWait),
192192
httpx.ResilientClientWithConnectionTimeout(timeout),

pipeline/authz/remote_json_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http"
1313
"net/http/httptest"
1414
"testing"
15+
"time"
1516

1617
"github.com/stretchr/testify/assert"
1718
"github.com/stretchr/testify/require"
@@ -408,3 +409,36 @@ func TestAuthorizerRemoteJSONTracePropagation(t *testing.T) {
408409
require.NoError(t, err)
409410
assert.NotEmpty(t, gotTraceparent, "expected traceparent header to be propagated to remote_json authorizer endpoint")
410411
}
412+
413+
// TestAuthorizerRemoteJSONHonorsMaxDelayTimeout is a regression test for a bug
414+
// where the parsed retry.max_delay duration was multiplied by an extra factor
415+
// of time.Millisecond, inflating the outbound HTTP timeout by 1e6 (a 50ms
416+
// setting became ~13.9 hours, effectively disabling the timeout). With the bug
417+
// the call blocks until the slow server responds; with the fix it times out
418+
// promptly.
419+
func TestAuthorizerRemoteJSONHonorsMaxDelayTimeout(t *testing.T) {
420+
t.Parallel()
421+
422+
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
423+
time.Sleep(2 * time.Second)
424+
w.WriteHeader(http.StatusOK)
425+
}))
426+
t.Cleanup(slow.Close)
427+
428+
config, err := sjson.SetBytes(
429+
[]byte(`{"payload":"{}","retry":{"max_delay":"50ms","give_up_after":"10ms"}}`), "remote", slow.URL)
430+
require.NoError(t, err)
431+
432+
reg := internal.NewRegistry(t)
433+
a := NewAuthorizerRemoteJSON(reg)
434+
r, err := http.NewRequestWithContext(t.Context(), "POST", "", nil)
435+
require.NoError(t, err)
436+
437+
start := time.Now()
438+
err = a.Authorize(r, &authn.AuthenticationSession{}, config, &rule.Rule{})
439+
elapsed := time.Since(start)
440+
441+
require.Error(t, err)
442+
assert.Less(t, elapsed, time.Second,
443+
"the configured 50ms max_delay must time the request out; the inflated value would block ~2s on the slow server")
444+
}

pipeline/authz/remote_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/http/httptest"
1414
"strings"
1515
"testing"
16+
"time"
1617

1718
"github.com/stretchr/testify/assert"
1819
"github.com/stretchr/testify/require"
@@ -337,3 +338,36 @@ func TestAuthorizerRemoteTracePropagation(t *testing.T) {
337338
require.NoError(t, err)
338339
assert.NotEmpty(t, gotTraceparent, "expected traceparent header to be propagated to remote authorizer endpoint")
339340
}
341+
342+
// TestAuthorizerRemoteHonorsMaxDelayTimeout is a regression test for a bug where
343+
// the parsed retry.max_delay duration was multiplied by an extra factor of
344+
// time.Millisecond, inflating the outbound HTTP timeout by 1e6 (a 50ms setting
345+
// became ~13.9 hours, effectively disabling the timeout). With the bug the call
346+
// blocks until the slow server responds; with the fix it times out promptly.
347+
func TestAuthorizerRemoteHonorsMaxDelayTimeout(t *testing.T) {
348+
t.Parallel()
349+
350+
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
351+
time.Sleep(2 * time.Second)
352+
w.WriteHeader(http.StatusOK)
353+
}))
354+
t.Cleanup(slow.Close)
355+
356+
config, err := sjson.SetBytes(
357+
[]byte(`{"retry":{"max_delay":"50ms","give_up_after":"10ms"}}`), "remote", slow.URL)
358+
require.NoError(t, err)
359+
360+
reg := internal.NewRegistry(t)
361+
a := NewAuthorizerRemote(reg)
362+
r, err := http.NewRequestWithContext(t.Context(), "POST", "", nil)
363+
require.NoError(t, err)
364+
r.Header.Set("Content-Type", "text/plain")
365+
366+
start := time.Now()
367+
err = a.Authorize(r, &authn.AuthenticationSession{}, config, &rule.Rule{})
368+
elapsed := time.Since(start)
369+
370+
require.Error(t, err)
371+
assert.Less(t, elapsed, time.Second,
372+
"the configured 50ms max_delay must time the request out; the inflated value would block ~2s on the slow server")
373+
}

0 commit comments

Comments
 (0)