Skip to content

Commit ba3fb05

Browse files
committed
feat: propagate 503/429 errors with retry information from OpenID configuration and token endpoints
- Return ServiceUnavailableError for 503 responses from both OpenID config and token endpoints - Return ThrottledError for 429 responses from both endpoints - Extract and include Retry-After header values in error responses to enable proper retry backoff - Add comprehensive test coverage for error scenarios in both provider and introspector components
1 parent 16464cb commit ba3fb05

File tree

7 files changed

+321
-8
lines changed

7 files changed

+321
-8
lines changed

idptoken/introspector.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,16 @@ func (i *Introspector) getWellKnownIntrospectionEndpointURL(ctx context.Context,
674674
openIDCfg, err := idputil.GetOpenIDConfiguration(
675675
ctx, i.HTTPClient, openIDCfgURL, nil, logger, i.promMetrics)
676676
if err != nil {
677+
var unexpectedRespErr *idputil.UnexpectedResponseError
678+
if errors.As(err, &unexpectedRespErr) {
679+
retryAfter := unexpectedRespErr.Header.Get("Retry-After")
680+
switch unexpectedRespErr.StatusCode {
681+
case http.StatusServiceUnavailable:
682+
return "", &ServiceUnavailableError{RetryAfter: retryAfter, Err: err}
683+
case http.StatusTooManyRequests:
684+
return "", &ThrottledError{RetryAfter: retryAfter, Err: err}
685+
}
686+
}
677687
return "", fmt.Errorf("get OpenID configuration: %w", err)
678688
}
679689
if openIDCfg.IntrospectionEndpoint == "" {

idptoken/introspector_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package idptoken_test
99
import (
1010
"context"
1111
"fmt"
12+
"net"
1213
"net/http"
1314
"net/url"
1415
"sync"
@@ -1454,3 +1455,88 @@ func TestIntrospector_IntrospectToken_ConcurrentCloning(t *gotesting.T) {
14541455
}
14551456
})
14561457
}
1458+
1459+
func TestIntrospector_OpenIDConfigurationErrors(t *gotesting.T) {
1460+
const validAccessToken = "access-token-with-introspection-permission"
1461+
const retryAfterValue = "120"
1462+
1463+
t.Run("error, dynamic introspection endpoint, openid config returns 503", func(t *gotesting.T) {
1464+
// Create a test server that returns 503 for OpenID configuration endpoint
1465+
testServer := http.NewServeMux()
1466+
testServer.HandleFunc(idptest.OpenIDConfigurationPath, func(w http.ResponseWriter, r *http.Request) {
1467+
w.Header().Set("Retry-After", retryAfterValue)
1468+
w.WriteHeader(http.StatusServiceUnavailable)
1469+
})
1470+
server := &http.Server{Addr: "127.0.0.1:0", Handler: testServer}
1471+
listener, err := net.Listen("tcp", server.Addr)
1472+
require.NoError(t, err)
1473+
defer func() { _ = listener.Close() }()
1474+
1475+
go func() { _ = server.Serve(listener) }()
1476+
defer func() { _ = server.Shutdown(context.Background()) }()
1477+
1478+
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
1479+
1480+
// Create a JWT token with this issuer
1481+
tokenToIntrospect := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{
1482+
RegisteredClaims: jwtgo.RegisteredClaims{
1483+
Issuer: serverURL,
1484+
Subject: uuid.NewString(),
1485+
ID: uuid.NewString(),
1486+
ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)),
1487+
},
1488+
})
1489+
1490+
introspector, err := idptoken.NewIntrospectorWithOpts(
1491+
idptest.NewSimpleTokenProvider(validAccessToken),
1492+
idptoken.IntrospectorOpts{},
1493+
)
1494+
require.NoError(t, err)
1495+
require.NoError(t, introspector.AddTrustedIssuerURL(serverURL))
1496+
1497+
_, err = introspector.IntrospectToken(context.Background(), tokenToIntrospect)
1498+
var svcUnavailableErr *idptoken.ServiceUnavailableError
1499+
require.ErrorAs(t, err, &svcUnavailableErr)
1500+
require.Equal(t, retryAfterValue, svcUnavailableErr.RetryAfter)
1501+
})
1502+
1503+
t.Run("error, dynamic introspection endpoint, openid config returns 429", func(t *gotesting.T) {
1504+
// Create a test server that returns 429 for OpenID configuration endpoint
1505+
testServer := http.NewServeMux()
1506+
testServer.HandleFunc(idptest.OpenIDConfigurationPath, func(w http.ResponseWriter, r *http.Request) {
1507+
w.Header().Set("Retry-After", retryAfterValue)
1508+
w.WriteHeader(http.StatusTooManyRequests)
1509+
})
1510+
server := &http.Server{Addr: "127.0.0.1:0", Handler: testServer}
1511+
listener, err := net.Listen("tcp", server.Addr)
1512+
require.NoError(t, err)
1513+
defer func() { _ = listener.Close() }()
1514+
1515+
go func() { _ = server.Serve(listener) }()
1516+
defer func() { _ = server.Shutdown(context.Background()) }()
1517+
1518+
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
1519+
1520+
// Create a JWT token with this issuer
1521+
tokenToIntrospect := idptest.MustMakeTokenStringSignedWithTestKey(&jwt.DefaultClaims{
1522+
RegisteredClaims: jwtgo.RegisteredClaims{
1523+
Issuer: serverURL,
1524+
Subject: uuid.NewString(),
1525+
ID: uuid.NewString(),
1526+
ExpiresAt: jwtgo.NewNumericDate(time.Now().Add(time.Hour)),
1527+
},
1528+
})
1529+
1530+
introspector, err := idptoken.NewIntrospectorWithOpts(
1531+
idptest.NewSimpleTokenProvider(validAccessToken),
1532+
idptoken.IntrospectorOpts{},
1533+
)
1534+
require.NoError(t, err)
1535+
require.NoError(t, introspector.AddTrustedIssuerURL(serverURL))
1536+
1537+
_, err = introspector.IntrospectToken(context.Background(), tokenToIntrospect)
1538+
var throttledErr *idptoken.ThrottledError
1539+
require.ErrorAs(t, err, &throttledErr)
1540+
require.Equal(t, retryAfterValue, throttledErr.RetryAfter)
1541+
})
1542+
}

idptoken/provider.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,16 @@ func (ti *oauth2Issuer) fetchTokenURL(ctx context.Context, customHeaders map[str
611611
openIDCfg, err := idputil.GetOpenIDConfiguration(
612612
ctx, ti.httpClient, openIDCfgURL, customHeaders, ti.logger, ti.promMetrics)
613613
if err != nil {
614+
var unexpectedRespErr *idputil.UnexpectedResponseError
615+
if errors.As(err, &unexpectedRespErr) {
616+
retryAfter := unexpectedRespErr.Header.Get("Retry-After")
617+
switch unexpectedRespErr.StatusCode {
618+
case http.StatusServiceUnavailable:
619+
return "", &ServiceUnavailableError{RetryAfter: retryAfter, Err: err}
620+
case http.StatusTooManyRequests:
621+
return "", &ThrottledError{RetryAfter: retryAfter, Err: err}
622+
}
623+
}
614624
return "", fmt.Errorf("(%s, %s): get OpenID configuration: %w", ti.baseURL, ti.clientID, err)
615625
}
616626
if _, err = url.ParseRequestURI(openIDCfg.TokenURL); err != nil {
@@ -660,6 +670,20 @@ func (ti *oauth2Issuer) issueToken(
660670
}
661671
}()
662672

673+
if resp.StatusCode != http.StatusOK {
674+
ti.promMetrics.ObserveHTTPClientRequest(
675+
http.MethodPost, tokenURL, resp.StatusCode, elapsed, metrics.HTTPRequestErrorUnexpectedStatusCode)
676+
retryAfter := resp.Header.Get("Retry-After")
677+
err := &UnexpectedIDPResponseError{HTTPCode: resp.StatusCode, IssueURL: ti.loadTokenURL()}
678+
switch resp.StatusCode {
679+
case http.StatusServiceUnavailable:
680+
return tokenData{}, &ServiceUnavailableError{RetryAfter: retryAfter, Err: err}
681+
case http.StatusTooManyRequests:
682+
return tokenData{}, &ThrottledError{RetryAfter: retryAfter, Err: err}
683+
}
684+
return tokenData{}, err
685+
}
686+
663687
tokenResponse := tokenResponseBody{}
664688
if err = json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
665689
ti.promMetrics.ObserveHTTPClientRequest(
@@ -669,12 +693,6 @@ func (ti *oauth2Issuer) issueToken(
669693
)
670694
}
671695

672-
if resp.StatusCode != http.StatusOK {
673-
ti.promMetrics.ObserveHTTPClientRequest(
674-
http.MethodPost, tokenURL, resp.StatusCode, elapsed, metrics.HTTPRequestErrorUnexpectedStatusCode)
675-
return tokenData{}, &UnexpectedIDPResponseError{HTTPCode: resp.StatusCode, IssueURL: ti.loadTokenURL()}
676-
}
677-
678696
ti.promMetrics.ObserveHTTPClientRequest(http.MethodPost, tokenURL, resp.StatusCode, elapsed, "")
679697
expires := time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn))
680698
ti.logger.Infof("(%s, %s): issued token, expires on %s", ti.loadTokenURL(), ti.clientID, expires.UTC())

idptoken/provider_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"context"
1111
"encoding/json"
1212
"fmt"
13+
"net"
1314
"net/http"
1415
"sync"
1516
"sync/atomic"
@@ -784,6 +785,178 @@ func TestProviderConcurrency(t *testing.T) {
784785
})
785786
}
786787

788+
func TestProvider_OpenIDConfigurationErrors(t *testing.T) {
789+
const retryAfterValue = "120"
790+
791+
t.Run("error, openid config returns 503", func(t *testing.T) {
792+
// Create a test server that returns 503 for OpenID configuration endpoint
793+
testServer := http.NewServeMux()
794+
testServer.HandleFunc(idptest.OpenIDConfigurationPath, func(w http.ResponseWriter, r *http.Request) {
795+
w.Header().Set("Retry-After", retryAfterValue)
796+
w.WriteHeader(http.StatusServiceUnavailable)
797+
})
798+
server := &http.Server{Addr: "127.0.0.1:0", Handler: testServer}
799+
listener, err := net.Listen("tcp", server.Addr)
800+
require.NoError(t, err)
801+
defer func() { _ = listener.Close() }()
802+
803+
go func() { _ = server.Serve(listener) }()
804+
defer func() { _ = server.Shutdown(context.Background()) }()
805+
806+
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
807+
808+
credentials := []idptoken.Source{
809+
{
810+
ClientID: testClientID,
811+
ClientSecret: "test-secret",
812+
URL: serverURL,
813+
},
814+
}
815+
// Use a custom HTTP client with minimal timeout and no retries
816+
httpClient := &http.Client{Timeout: 2 * time.Second}
817+
opts := idptoken.ProviderOpts{
818+
HTTPClient: httpClient,
819+
}
820+
provider := idptoken.NewMultiSourceProviderWithOpts(credentials, opts)
821+
822+
_, err = provider.GetToken(context.Background(), testClientID, serverURL)
823+
var svcUnavailableErr *idptoken.ServiceUnavailableError
824+
require.ErrorAs(t, err, &svcUnavailableErr)
825+
require.Equal(t, retryAfterValue, svcUnavailableErr.RetryAfter)
826+
})
827+
828+
t.Run("error, openid config returns 429", func(t *testing.T) {
829+
// Create a test server that returns 429 for OpenID configuration endpoint
830+
testServer := http.NewServeMux()
831+
testServer.HandleFunc(idptest.OpenIDConfigurationPath, func(w http.ResponseWriter, r *http.Request) {
832+
w.Header().Set("Retry-After", retryAfterValue)
833+
w.WriteHeader(http.StatusTooManyRequests)
834+
})
835+
server := &http.Server{Addr: "127.0.0.1:0", Handler: testServer}
836+
listener, err := net.Listen("tcp", server.Addr)
837+
require.NoError(t, err)
838+
defer func() { _ = listener.Close() }()
839+
840+
go func() { _ = server.Serve(listener) }()
841+
defer func() { _ = server.Shutdown(context.Background()) }()
842+
843+
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
844+
845+
credentials := []idptoken.Source{
846+
{
847+
ClientID: testClientID,
848+
ClientSecret: "test-secret",
849+
URL: serverURL,
850+
},
851+
}
852+
// Use a custom HTTP client with minimal timeout and no retries
853+
httpClient := &http.Client{Timeout: 2 * time.Second}
854+
opts := idptoken.ProviderOpts{
855+
HTTPClient: httpClient,
856+
}
857+
provider := idptoken.NewMultiSourceProviderWithOpts(credentials, opts)
858+
859+
_, err = provider.GetToken(context.Background(), testClientID, serverURL)
860+
var throttledErr *idptoken.ThrottledError
861+
require.ErrorAs(t, err, &throttledErr)
862+
require.Equal(t, retryAfterValue, throttledErr.RetryAfter)
863+
})
864+
}
865+
866+
func TestProvider_TokenEndpointErrors(t *testing.T) {
867+
const retryAfterValue = "120"
868+
869+
t.Run("error, token endpoint returns 503", func(t *testing.T) {
870+
// Create a test server that returns 503 for token endpoint
871+
testServer := http.NewServeMux()
872+
// OpenID configuration needs to be served to get the token URL
873+
testServer.HandleFunc(idptest.OpenIDConfigurationPath, func(w http.ResponseWriter, r *http.Request) {
874+
w.Header().Set("Content-Type", "application/json")
875+
resp := map[string]string{
876+
"token_endpoint": fmt.Sprintf("http://%s%s", r.Host, idptest.TokenEndpointPath),
877+
}
878+
_ = json.NewEncoder(w).Encode(resp)
879+
})
880+
testServer.HandleFunc(idptest.TokenEndpointPath, func(w http.ResponseWriter, r *http.Request) {
881+
w.Header().Set("Retry-After", retryAfterValue)
882+
w.WriteHeader(http.StatusServiceUnavailable)
883+
})
884+
server := &http.Server{Addr: "127.0.0.1:0", Handler: testServer}
885+
listener, err := net.Listen("tcp", server.Addr)
886+
require.NoError(t, err)
887+
defer func() { _ = listener.Close() }()
888+
889+
go func() { _ = server.Serve(listener) }()
890+
defer func() { _ = server.Shutdown(context.Background()) }()
891+
892+
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
893+
894+
credentials := []idptoken.Source{
895+
{
896+
ClientID: testClientID,
897+
ClientSecret: "test-secret",
898+
URL: serverURL,
899+
},
900+
}
901+
// Use a custom HTTP client with minimal timeout and no retries
902+
httpClient := &http.Client{Timeout: 2 * time.Second}
903+
opts := idptoken.ProviderOpts{
904+
HTTPClient: httpClient,
905+
}
906+
provider := idptoken.NewMultiSourceProviderWithOpts(credentials, opts)
907+
908+
_, err = provider.GetToken(context.Background(), testClientID, serverURL)
909+
var svcUnavailableErr *idptoken.ServiceUnavailableError
910+
require.ErrorAs(t, err, &svcUnavailableErr)
911+
require.Equal(t, retryAfterValue, svcUnavailableErr.RetryAfter)
912+
})
913+
914+
t.Run("error, token endpoint returns 429", func(t *testing.T) {
915+
// Create a test server that returns 429 for token endpoint
916+
testServer := http.NewServeMux()
917+
// OpenID configuration needs to be served to get the token URL
918+
testServer.HandleFunc(idptest.OpenIDConfigurationPath, func(w http.ResponseWriter, r *http.Request) {
919+
w.Header().Set("Content-Type", "application/json")
920+
resp := map[string]string{
921+
"token_endpoint": fmt.Sprintf("http://%s%s", r.Host, idptest.TokenEndpointPath),
922+
}
923+
_ = json.NewEncoder(w).Encode(resp)
924+
})
925+
testServer.HandleFunc(idptest.TokenEndpointPath, func(w http.ResponseWriter, r *http.Request) {
926+
w.Header().Set("Retry-After", retryAfterValue)
927+
w.WriteHeader(http.StatusTooManyRequests)
928+
})
929+
server := &http.Server{Addr: "127.0.0.1:0", Handler: testServer}
930+
listener, err := net.Listen("tcp", server.Addr)
931+
require.NoError(t, err)
932+
defer func() { _ = listener.Close() }()
933+
934+
go func() { _ = server.Serve(listener) }()
935+
defer func() { _ = server.Shutdown(context.Background()) }()
936+
937+
serverURL := fmt.Sprintf("http://%s", listener.Addr().String())
938+
939+
credentials := []idptoken.Source{
940+
{
941+
ClientID: testClientID,
942+
ClientSecret: "test-secret",
943+
URL: serverURL,
944+
},
945+
}
946+
// Use a custom HTTP client with minimal timeout and no retries
947+
httpClient := &http.Client{Timeout: 2 * time.Second}
948+
opts := idptoken.ProviderOpts{
949+
HTTPClient: httpClient,
950+
}
951+
provider := idptoken.NewMultiSourceProviderWithOpts(credentials, opts)
952+
953+
_, err = provider.GetToken(context.Background(), testClientID, serverURL)
954+
var throttledErr *idptoken.ThrottledError
955+
require.ErrorAs(t, err, &throttledErr)
956+
require.Equal(t, retryAfterValue, throttledErr.RetryAfter)
957+
})
958+
}
959+
787960
type claimsProviderWithExpiration struct {
788961
ExpTime time.Duration
789962
}

internal/idputil/errors.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
Copyright © 2025 Acronis International GmbH.
3+
4+
Released under MIT license.
5+
*/
6+
7+
package idputil
8+
9+
import (
10+
"fmt"
11+
"net/http"
12+
)
13+
14+
// UnexpectedResponseError represents an error that occurs when an unexpected HTTP response is received.
15+
// It captures the HTTP status code and response headers for further analysis.
16+
type UnexpectedResponseError struct {
17+
StatusCode int
18+
Header http.Header
19+
}
20+
21+
func (e *UnexpectedResponseError) Error() string {
22+
return fmt.Sprintf("unexpected HTTP status code %d", e.StatusCode)
23+
}

internal/idputil/openid_configuration.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ func GetOpenIDConfiguration(
5858
if resp.StatusCode != http.StatusOK {
5959
promMetrics.ObserveHTTPClientRequest(
6060
http.MethodGet, targetURL, resp.StatusCode, elapsed, metrics.HTTPRequestErrorUnexpectedStatusCode)
61-
return OpenIDConfiguration{}, fmt.Errorf("unexpected HTTP code %d", resp.StatusCode)
61+
return OpenIDConfiguration{}, &UnexpectedResponseError{
62+
StatusCode: resp.StatusCode,
63+
Header: resp.Header.Clone(),
64+
}
6265
}
6366

6467
var openIDCfg OpenIDConfiguration

jwks/client_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestClient_GetRSAPublicKey(t *testing.T) {
6565
var openIDCfgErr *jwks.GetOpenIDConfigurationError
6666
require.True(t, errors.As(err, &openIDCfgErr))
6767
require.Equal(t, issuerConfigServer.URL+idputil.OpenIDConfigurationPath, openIDCfgErr.URL)
68-
require.EqualError(t, openIDCfgErr.Inner, "unexpected HTTP code 500")
68+
require.EqualError(t, openIDCfgErr.Inner, "unexpected HTTP status code 500")
6969
require.Nil(t, pubKey)
7070
})
7171

0 commit comments

Comments
 (0)