From 594587570f45e656aca64211ac0cdf48f813c75f Mon Sep 17 00:00:00 2001 From: Meysam Tamkin Date: Thu, 11 Jun 2026 17:43:12 +0400 Subject: [PATCH] feat: add resilience layer for Authlete API calls (rate limit best practices) Implements caching, conditional retry with exponential backoff and jitter, and per-endpoint circuit breakers with stale-cache fallback, as described in the Rate Limit Best Practices guide. All endpoints obtain the AuthleteApi client through a dynamic proxy wrapper; behavior is tunable via resilience.properties and can be disabled entirely with resilience.enabled=false. --- pom.xml | 8 + .../api/AuthorizationDecisionEndpoint.java | 4 +- .../server/api/AuthorizationEndpoint.java | 4 +- .../api/ClientRegistrationEndpoint.java | 10 +- .../server/api/ConfigurationEndpoint.java | 4 +- .../api/FederationConfigurationEndpoint.java | 4 +- .../api/FederationRegistrationEndpoint.java | 6 +- .../server/api/GrantManagementEndpoint.java | 6 +- .../server/api/IntrospectionEndpoint.java | 4 +- .../jaxrs/server/api/JwksEndpoint.java | 4 +- .../server/api/PushedAuthReqEndpoint.java | 4 +- .../jaxrs/server/api/RevocationEndpoint.java | 4 +- .../jaxrs/server/api/TokenEndpoint.java | 4 +- .../jaxrs/server/api/UserInfoEndpoint.java | 4 +- .../AttestationChallengeEndpoint.java | 4 +- ...channelAuthenticationCallbackEndpoint.java | 4 +- .../BackchannelAuthenticationEndpoint.java | 4 +- .../BaseAuthenticationDeviceProcessor.java | 4 +- .../device/DeviceAuthorizationEndpoint.java | 4 +- .../api/device/DeviceCompleteEndpoint.java | 4 +- .../device/DeviceVerificationEndpoint.java | 4 +- .../server/api/obb/AccountsEndpoint.java | 4 +- .../server/api/obb/ConsentsEndpoint.java | 8 +- .../api/obb/FAPI2BaseAccountsEndpoint.java | 4 +- .../server/api/obb/ResourcesEndpoint.java | 4 +- .../api/vci/BatchCredentialEndpoint.java | 4 +- .../server/api/vci/CredentialEndpoint.java | 4 +- .../api/vci/CredentialJWKSetEndpoint.java | 4 +- .../api/vci/CredentialJwtIssuerEndpoint.java | 4 +- .../api/vci/CredentialMetadataEndpoint.java | 4 +- .../api/vci/CredentialNonceEndpoint.java | 4 +- .../api/vci/CredentialOfferEndpoint.java | 4 +- .../api/vci/CredentialOfferIssueEndpoint.java | 4 +- .../api/vci/DeferredCredentialEndpoint.java | 4 +- .../server/resilience/AuthleteBackoff.java | 111 ++++++ .../resilience/AuthleteCacheableMethods.java | 273 +++++++++++++++ .../resilience/AuthleteCircuitBreaker.java | 208 +++++++++++ .../AuthleteCircuitBreakerRegistry.java | 72 ++++ .../resilience/AuthleteResponseCache.java | 203 +++++++++++ .../resilience/AuthleteRetryPolicy.java | 152 ++++++++ .../server/resilience/ResilienceConfig.java | 231 ++++++++++++ .../resilience/ResilienceProperties.java | 89 +++++ .../ResilientAuthleteApiFactory.java | 107 ++++++ ...ResilientAuthleteApiInvocationHandler.java | 329 ++++++++++++++++++ src/main/resources/resilience.properties | 77 ++++ .../resilience/AuthleteBackoffTest.java | 76 ++++ .../AuthleteCacheableMethodsTest.java | 143 ++++++++ .../AuthleteCircuitBreakerTest.java | 151 ++++++++ .../resilience/AuthleteResponseCacheTest.java | 95 +++++ .../resilience/AuthleteRetryPolicyTest.java | 93 +++++ 50 files changed, 2491 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/AuthleteBackoff.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java create mode 100644 src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java create mode 100644 src/main/resources/resilience.properties create mode 100644 src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java create mode 100644 src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java create mode 100644 src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java create mode 100644 src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java create mode 100644 src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java diff --git a/pom.xml b/pom.xml index f8bbb54..affeba5 100644 --- a/pom.xml +++ b/pom.xml @@ -157,6 +157,14 @@ gson 2.10.1 + + + + junit + junit + 4.13.2 + test + diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java index eb41d10..a8e51fa 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java @@ -29,7 +29,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.Client; import com.authlete.common.types.User; import com.authlete.jaxrs.AuthorizationDecisionHandler.Params; @@ -113,7 +113,7 @@ public Response post( session.getId()); // Handle the end-user's decision. - return handle(AuthleteApiFactory.getDefaultApi(), spi, params); + return handle(ResilientAuthleteApiFactory.getDefaultApi(), spi, params); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java index d21fc0b..efd3e6b 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java @@ -27,7 +27,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BaseAuthorizationEndpoint; @@ -104,7 +104,7 @@ public Response post( */ private Response handle(HttpServletRequest request, MultivaluedMap parameters) { - return handle(AuthleteApiFactory.getDefaultApi(), + return handle(ResilientAuthleteApiFactory.getDefaultApi(), new AuthorizationRequestHandlerSpiImpl(request), parameters); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java index 58631f5..39f7cdd 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java @@ -34,7 +34,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.util.Utils; import com.authlete.jaxrs.BaseClientRegistrationEndpoint; import com.authlete.jaxrs.server.obb.util.ObbUtils; @@ -71,7 +71,7 @@ public Response register( @Context HttpServletRequest httpServletRequest) { // The interface of Authlete APIs. - AuthleteApi api = AuthleteApiFactory.getDefaultApi(); + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); // Pre-process the request body as necessary. json = preprocessRequestBody(httpServletRequest, json); @@ -92,7 +92,7 @@ public Response read( @Context HttpServletRequest httpServletRequest) { // The interface of Authlete APIs. - AuthleteApi api = AuthleteApiFactory.getDefaultApi(); + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); // Extra process before executing the "read" operation. preprocessClient(httpServletRequest, api, clientId); @@ -115,7 +115,7 @@ public Response update( @Context HttpServletRequest httpServletRequest) { // The interface of Authlete APIs. - AuthleteApi api = AuthleteApiFactory.getDefaultApi(); + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); // Pre-process the request body as necessary. json = preprocessRequestBody(httpServletRequest, json); @@ -136,7 +136,7 @@ public Response delete( @Context HttpServletRequest httpServletRequest) { // The interface of Authlete APIs. - AuthleteApi api = AuthleteApiFactory.getDefaultApi(); + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); // Extra process before executing the "delete" operation. preprocessClient(httpServletRequest, api, clientId); diff --git a/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java index e0d1523..25034bd 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java @@ -22,7 +22,7 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.ServiceConfigurationRequest; import com.authlete.jaxrs.BaseConfigurationEndpoint; @@ -105,7 +105,7 @@ public Response get( ) { // An AuthleteApi instance to access Authlete APIs. - AuthleteApi api = AuthleteApiFactory.getDefaultApi(); + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); // If either or both of the 'pretty' request parameter // and the 'patch' request parameter are given. diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java index b2d63bf..5e880f5 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java @@ -20,7 +20,7 @@ import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.FederationConfigurationRequest; import com.authlete.common.types.EntityType; import com.authlete.jaxrs.BaseFederationConfigurationEndpoint; @@ -78,6 +78,6 @@ public class FederationConfigurationEndpoint extends BaseFederationConfiguration public Response get() { // Handle the request to the endpoint. - return handle(AuthleteApiFactory.getDefaultApi(), REQUEST); + return handle(ResilientAuthleteApiFactory.getDefaultApi(), REQUEST); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java index 51c722e..d0468c4 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java @@ -21,7 +21,7 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.FederationRegistrationRequest; import com.authlete.jaxrs.BaseFederationRegistrationEndpoint; @@ -89,7 +89,7 @@ public Response entityConfiguration(String jwt) { // Client registration by a relying party's entity configuration. return handle( - AuthleteApiFactory.getDefaultApi(), + ResilientAuthleteApiFactory.getDefaultApi(), request().setEntityConfiguration(jwt)); } @@ -100,7 +100,7 @@ public Response trustChain(String json) { // Client registration by a relying party's trust chain. return handle( - AuthleteApiFactory.getDefaultApi(), + ResilientAuthleteApiFactory.getDefaultApi(), request().setTrustChain(json)); } diff --git a/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java index f379641..3d26d1c 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java @@ -24,7 +24,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BaseGrantManagementEndpoint; @@ -47,7 +47,7 @@ public Response query( @PathParam("grantId") String grantId) { // Handle the grant management 'query' request. - return handle(AuthleteApiFactory.getDefaultApi(), req, grantId); + return handle(ResilientAuthleteApiFactory.getDefaultApi(), req, grantId); } @@ -61,6 +61,6 @@ public Response revoke( @PathParam("grantId") String grantId) { // Handle the grant management 'revoke' request. - return handle(AuthleteApiFactory.getDefaultApi(), req, grantId); + return handle(ResilientAuthleteApiFactory.getDefaultApi(), req, grantId); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java index 8294935..8988a6c 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java @@ -26,7 +26,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.web.BasicCredentials; import com.authlete.jaxrs.BaseIntrospectionEndpoint; import com.authlete.jaxrs.IntrospectionRequestHandler.Params; @@ -104,7 +104,7 @@ public Response post( Params params = buildParams(parameters, accept, rsEntity); // Handle the introspection request. - return handle(AuthleteApiFactory.getDefaultApi(), params); + return handle(ResilientAuthleteApiFactory.getDefaultApi(), params); } diff --git a/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java index 8714fb9..a9e7e4d 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java @@ -20,7 +20,7 @@ import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BaseJwksEndpoint; @@ -60,6 +60,6 @@ public class JwksEndpoint extends BaseJwksEndpoint public Response get() { // Handle the JWK Set request. - return handle(AuthleteApiFactory.getDefaultApi()); + return handle(ResilientAuthleteApiFactory.getDefaultApi()); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java index 00dca3c..5e3cc4f 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java @@ -11,7 +11,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BasePushedAuthReqEndpoint; import com.authlete.jaxrs.PushedAuthReqHandler.Params; @@ -40,7 +40,7 @@ public Response post( MultivaluedMap parameters) { // Authlete API - AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi(); + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); // Parameters for Authlete's pushed_auth_req API. Params params = buildParams(request, parameters); diff --git a/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java index 7455c8b..66514fc 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java @@ -27,7 +27,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BaseRevocationEndpoint; import com.authlete.jaxrs.RevocationRequestHandler.Params; @@ -57,7 +57,7 @@ public Response post( MultivaluedMap parameters) { // Authlete API - AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi(); + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); // Parameters for Authlete's /auth/revocation API Params params = buildParams(request, parameters); diff --git a/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java index 034f1c6..0d377b6 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java @@ -28,7 +28,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.util.Utils; import com.authlete.jaxrs.BaseTokenEndpoint; import com.authlete.jaxrs.TokenRequestHandler.Params; @@ -80,7 +80,7 @@ public Response post( MultivaluedMap parameters) { // Authlete API - AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi(); + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); // Process the token request in a standard way. Response response = processTokenRequest(authleteApi, request, parameters); diff --git a/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java index 02af84e..42f79e3 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java @@ -26,7 +26,7 @@ import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BaseUserInfoEndpoint; import com.authlete.jaxrs.UserInfoRequestHandler.Params; import com.authlete.jaxrs.util.JaxRsUtils; @@ -124,7 +124,7 @@ private Response handle( { Params params = buildParams(request, body, accessToken, dpop); - return handle(AuthleteApiFactory.getDefaultApi(), + return handle(ResilientAuthleteApiFactory.getDefaultApi(), new UserInfoRequestHandlerSpiImpl(), params); } diff --git a/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java index 2198cd5..4b1737e 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java @@ -21,7 +21,7 @@ import javax.ws.rs.Path; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.AttestationChallengeRequest; import com.authlete.jaxrs.BaseAttestationChallengeEndpoint; @@ -60,7 +60,7 @@ public class AttestationChallengeEndpoint extends BaseAttestationChallengeEndpoi public Response post() { // Authlete API interface - AuthleteApi api = AuthleteApiFactory.getDefaultApi(); + AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi(); // Request to the Authlete's /api/{service-id}/attestation/challenge API AttestationChallengeRequest request = diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java index 4a3a1cb..deb3eac 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java @@ -27,7 +27,7 @@ import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result; import com.authlete.common.types.User; import com.authlete.jaxrs.BackchannelAuthenticationCompleteRequestHandler; @@ -106,7 +106,7 @@ private Response doProcess(AsyncAuthenticationCallbackRequest request) // Complete the authentication and authorization process. new BackchannelAuthenticationCompleteRequestHandler( - AuthleteApiFactory.getDefaultApi(), + ResilientAuthleteApiFactory.getDefaultApi(), new BackchannelAuthenticationCompleteHandlerSpiImpl( result, user, authTime, acrs, errorDescription, null) ) diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java index bdeb40d..434d45f 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java @@ -27,7 +27,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BackchannelAuthenticationRequestHandler.Params; import com.authlete.jaxrs.BaseBackchannelAuthenticationEndpoint; @@ -51,7 +51,7 @@ public Response post( MultivaluedMap parameters) { // Authlete API - AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi(); + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); // Parameters for Authlete's /backchannel/authentication API Params params = buildParams(request, parameters); diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java index e86032c..af58baa 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java @@ -21,7 +21,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.dto.Scope; import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result; import com.authlete.common.types.User; @@ -296,7 +296,7 @@ protected void completeWithTransactionFailed() protected void complete(Result result, Date authTime, String errorDescription, URI errorUri) { new BackchannelAuthenticationCompleteRequestHandler( - AuthleteApiFactory.getDefaultApi(), + ResilientAuthleteApiFactory.getDefaultApi(), new BackchannelAuthenticationCompleteHandlerSpiImpl( result, mUser, authTime, mAcrs, errorDescription, errorUri) ) diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java index caf0824..ae07cb2 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java @@ -27,7 +27,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import com.authlete.common.api.AuthleteApi; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jaxrs.BaseDeviceAuthorizationEndpoint; import com.authlete.jaxrs.DeviceAuthorizationRequestHandler.Params; @@ -54,7 +54,7 @@ public Response post( MultivaluedMap parameters) { // Authlete API - AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi(); + AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi(); // Parameters for Authlete's /device/authorization API Params params = buildParams(request, parameters); diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java index 2e320dd..8f74f60 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java @@ -28,7 +28,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.types.User; import com.authlete.jaxrs.BaseDeviceCompleteEndpoint; @@ -125,7 +125,7 @@ private Response handle( MultivaluedMap parameters, User user, Date userAuthenticatedAt, String[] acrs, String userCode, String[] claimNames) { - return handle(AuthleteApiFactory.getDefaultApi(), new DeviceCompleteRequestHandlerSpiImpl( + return handle(ResilientAuthleteApiFactory.getDefaultApi(), new DeviceCompleteRequestHandlerSpiImpl( parameters, user, userAuthenticatedAt, acrs), userCode, claimNames); } } diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java index 4a66835..92950b8 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java +++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java @@ -32,7 +32,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; import org.glassfish.jersey.server.mvc.Viewable; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.common.types.User; import com.authlete.jaxrs.BaseDeviceVerificationEndpoint; import com.authlete.jaxrs.DeviceVerificationPageModel; @@ -174,7 +174,7 @@ private void authenticateUser(HttpSession session, MultivaluedMap + * Jitter spreads retries from many clients across time so they do not all + * fire simultaneously and create a renewed traffic spike. When the server + * tells us exactly how long to wait (via a {@code RateLimit-Reset} header on a + * {@code 429}), that value is honoured instead of the computed delay. + *

+ */ +class AuthleteBackoff +{ + private final long baseDelayMillis; + private final long maxDelayMillis; + private final long jitterMillis; + private final Random random; + + + AuthleteBackoff(long baseDelayMillis, long maxDelayMillis, long jitterMillis) + { + this(baseDelayMillis, maxDelayMillis, jitterMillis, new Random()); + } + + + /** + * Package-private constructor that allows an injected {@link Random} for + * deterministic testing. + */ + AuthleteBackoff(long baseDelayMillis, long maxDelayMillis, long jitterMillis, Random random) + { + this.baseDelayMillis = baseDelayMillis; + this.maxDelayMillis = maxDelayMillis; + this.jitterMillis = jitterMillis; + this.random = random; + } + + + /** + * Compute the delay (milliseconds) before the given retry attempt. + * + * @param attempt + * The 1-based retry number (1 = first retry, 2 = second retry, ...). + * + * @param explicitDelayMillis + * An explicit delay requested by the server (e.g. from a + * {@code RateLimit-Reset} header), or {@code null} if none. When + * present, it forms the base delay instead of the exponential value. + * + * @return + * The delay to sleep, clamped to {@code [0, maxDelayMillis]}. + */ + long delayMillis(int attempt, Long explicitDelayMillis) + { + long base; + + if (explicitDelayMillis != null) + { + // Honour the server's instruction. + base = explicitDelayMillis; + } + else + { + // baseDelay * 2^(attempt-1), guarding against overflow. + int shift = Math.max(0, attempt - 1); + + if (shift >= 62) + { + base = maxDelayMillis; + } + else + { + base = baseDelayMillis << shift; + } + } + + long jitter = (jitterMillis > 0) ? (long) (random.nextDouble() * jitterMillis) : 0L; + + long delay = base + jitter; + + if (delay < 0 || delay > maxDelayMillis) + { + delay = maxDelayMillis; + } + + return delay; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java new file mode 100644 index 0000000..f432008 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.lang.reflect.Method; +import com.authlete.common.dto.CredentialIssuerJwksRequest; +import com.authlete.common.dto.CredentialIssuerMetadataRequest; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.ServiceConfigurationRequest; +import com.authlete.common.dto.StandardIntrospectionRequest; + + +/** + * Decides which {@link com.authlete.common.api.AuthleteApi AuthleteApi} methods + * are cacheable, and builds a stable cache key and TTL for each call. + * + *

+ * Only the idempotent read endpoints named in Authlete's "Rate Limit Best + * Practices" guide are cached: + *

+ *
    + *
  • {@code getServiceConfiguration} — service discovery document
  • + *
  • {@code getServiceJwks} — service JWK Set
  • + *
  • {@code getClient} — client metadata
  • + *
  • {@code credentialIssuerMetadata} — OID4VCI issuer metadata
  • + *
  • {@code credentialIssuerJwks} — OID4VCI issuer JWK Set
  • + *
  • {@code introspection} / {@code standardIntrospection} — token + * introspection (short TTL; see {@link CachePolicy#capByTokenExpiry})
  • + *
+ * + *

+ * Any other method returns {@code null} from {@link #policyFor(Method, Object[])} + * and is therefore never cached. + *

+ */ +class AuthleteCacheableMethods +{ + /** + * The decision for a single cacheable call: the namespaced cache key and the + * TTL to apply. + */ + static final class CachePolicy + { + final String key; + final long ttlMillis; + + /** + * When true, the effective TTL must additionally be capped so the entry + * never outlives the token's own expiry (introspection only). + */ + final boolean capByTokenExpiry; + + CachePolicy(String key, long ttlMillis, boolean capByTokenExpiry) + { + this.key = key; + this.ttlMillis = ttlMillis; + this.capByTokenExpiry = capByTokenExpiry; + } + } + + + private final ResilienceConfig config; + + + AuthleteCacheableMethods(ResilienceConfig config) + { + this.config = config; + } + + + /** + * Return the caching policy for the given method invocation, or {@code null} + * if the method must not be cached. + */ + CachePolicy policyFor(Method method, Object[] args) + { + String name = method.getName(); + int argc = (args == null) ? 0 : args.length; + + switch (name) + { + case "getServiceConfiguration": + return serviceConfiguration(args, argc); + + case "getServiceJwks": + return key("getServiceJwks", joinArgs(args), + config.getCacheTtlServiceJwks(), false); + + case "getClient": + // getClient(long) and getClient(String); both identify one client. + return key("getClient", String.valueOf(args[0]), + config.getCacheTtlClient(), false); + + case "credentialIssuerMetadata": + if (args[0] instanceof CredentialIssuerMetadataRequest) + { + CredentialIssuerMetadataRequest req = (CredentialIssuerMetadataRequest) args[0]; + return key("credentialIssuerMetadata", String.valueOf(req.isPretty()), + config.getCacheTtlCredentialIssuerMetadata(), false); + } + return null; + + case "credentialIssuerJwks": + if (args[0] instanceof CredentialIssuerJwksRequest) + { + CredentialIssuerJwksRequest req = (CredentialIssuerJwksRequest) args[0]; + return key("credentialIssuerJwks", String.valueOf(req.isPretty()), + config.getCacheTtlCredentialIssuerJwks(), false); + } + return null; + + case "introspection": + if (args[0] instanceof IntrospectionRequest) + { + return introspection((IntrospectionRequest) args[0]); + } + return null; + + case "standardIntrospection": + if (args[0] instanceof StandardIntrospectionRequest) + { + return standardIntrospection((StandardIntrospectionRequest) args[0]); + } + return null; + + default: + return null; + } + } + + + private CachePolicy serviceConfiguration(Object[] args, int argc) + { + long ttl = config.getCacheTtlServiceConfiguration(); + + if (argc == 1 && args[0] instanceof ServiceConfigurationRequest) + { + ServiceConfigurationRequest req = (ServiceConfigurationRequest) args[0]; + String detail = req.isPretty() + "|" + req.getPatch(); + return key("getServiceConfiguration", detail, ttl, false); + } + + // getServiceConfiguration() or getServiceConfiguration(boolean). + return key("getServiceConfiguration", joinArgs(args), ttl, false); + } + + + private CachePolicy introspection(IntrospectionRequest req) + { + // A DPoP proof is unique per request (jti/iat), and HTTP message + // signature inputs vary per request too. A cached result could never + // be legitimately reused for them, and reusing one would skip the + // per-request proof validation, so such calls are never cached. + if (req.getDpop() != null + || req.getMessage() != null + || req.getHeaders() != null + || req.getRequiredComponents() != null) + { + return null; + } + + // The key must capture every remaining input that influences the + // introspection result, so that requests differing in any binding + // (e.g. mTLS client certificate or target URI) never share an entry. + StringBuilder detail = new StringBuilder(); + detail.append(req.getToken()); + detail.append('|').append(join(req.getScopes())); + detail.append('|').append(req.getSubject()); + detail.append('|').append(req.getClientCertificate()); + detail.append('|').append(req.getHtm()); + detail.append('|').append(req.getHtu()); + detail.append('|').append(join(req.getResources())); + detail.append('|').append(req.getUri()); + detail.append('|').append(req.getTargetUri()); + detail.append('|').append(join(req.getAcrValues())); + detail.append('|').append(req.getMaxAge()); + detail.append('|').append(req.isRequestBodyContained()); + detail.append('|').append(req.isDpopNonceRequired()); + + return key("introspection", detail.toString(), + config.getCacheTtlIntrospection(), true); + } + + + private CachePolicy standardIntrospection(StandardIntrospectionRequest req) + { + // Besides the token parameters, the response depends on the resource + // server's identity and the requested response format/protection, so + // all of them participate in the key. Otherwise one resource server + // could receive a response cached for another. + StringBuilder detail = new StringBuilder(); + detail.append(req.getParameters()); + detail.append('|').append(req.isWithHiddenProperties()); + detail.append('|').append(req.getRsUri()); + detail.append('|').append(req.getHttpAcceptHeader()); + detail.append('|').append(req.getIntrospectionSignAlg()); + detail.append('|').append(req.getIntrospectionEncryptionAlg()); + detail.append('|').append(req.getIntrospectionEncryptionEnc()); + detail.append('|').append(req.getSharedKeyForSign()); + detail.append('|').append(req.getSharedKeyForEncryption()); + detail.append('|').append(req.getPublicKeyForEncryption()); + + return key("standardIntrospection", detail.toString(), + config.getCacheTtlStandardIntrospection(), true); + } + + + private static CachePolicy key(String namespace, String detail, long ttlMillis, boolean capByTokenExpiry) + { + // Token (and similar) values can be long; namespacing keeps lookups O(1) + // in the shared map without risk of cross-method collisions. + return new CachePolicy(namespace + "::" + detail, ttlMillis, capByTokenExpiry); + } + + + private static String joinArgs(Object[] args) + { + if (args == null || args.length == 0) + { + return ""; + } + + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < args.length; i++) + { + if (i > 0) + { + sb.append('|'); + } + sb.append(String.valueOf(args[i])); + } + + return sb.toString(); + } + + + private static String join(Object[] values) + { + if (values == null || values.length == 0) + { + return ""; + } + + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < values.length; i++) + { + if (i > 0) + { + sb.append(','); + } + sb.append(values[i]); + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java new file mode 100644 index 0000000..fcde803 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.function.LongSupplier; + + +/** + * A classic three-state circuit breaker (Closed / Open / Half-Open) guarding a + * single Authlete API method. + * + *
    + *
  • Closed — normal operation; transient failures are counted + * within a rolling window. Reaching the failure threshold trips the + * breaker Open.
  • + *
  • Open — calls fail fast without touching the backend. After + * the open timeout elapses, the breaker moves to Half-Open.
  • + *
  • Half-Open — a limited number of trial calls are allowed + * through. A success closes the breaker; a failure reopens it.
  • + *
+ * + *

+ * One breaker is kept per API method (see {@link AuthleteCircuitBreakerRegistry}) so + * that failures in one endpoint (e.g. client management) do not block an + * unrelated, higher-priority endpoint (e.g. introspection). + *

+ * + *

+ * All state transitions are guarded by intrinsic locking; the breaker is safe + * for concurrent use. + *

+ */ +class AuthleteCircuitBreaker +{ + enum State + { + CLOSED, + OPEN, + HALF_OPEN + } + + + private final int failureThreshold; + private final long windowMillis; + private final long openMillis; + private final int halfOpenTrials; + private final LongSupplier clock; + + private State state = State.CLOSED; + private int failureCount = 0; + private boolean windowOpen = false; + private long windowStart = 0L; + private long openedAt = 0L; + private int halfOpenInFlight = 0; + + + AuthleteCircuitBreaker(int failureThreshold, long windowMillis, long openMillis, int halfOpenTrials) + { + this(failureThreshold, windowMillis, openMillis, halfOpenTrials, System::currentTimeMillis); + } + + + /** + * Package-private constructor that allows an injected clock for testing. + */ + AuthleteCircuitBreaker(int failureThreshold, long windowMillis, long openMillis, + int halfOpenTrials, LongSupplier clock) + { + this.failureThreshold = failureThreshold; + this.windowMillis = windowMillis; + this.openMillis = openMillis; + this.halfOpenTrials = Math.max(1, halfOpenTrials); + this.clock = clock; + } + + + /** + * Decide whether a request may proceed right now. When this returns + * {@code true} in the half-open state, a trial slot is reserved and must be + * released via {@link #recordSuccess()} or {@link #recordFailure()}. + */ + synchronized boolean allowRequest() + { + long now = clock.getAsLong(); + + switch (state) + { + case CLOSED: + return true; + + case OPEN: + if (now - openedAt >= openMillis) + { + // Time to probe whether the backend has recovered. + state = State.HALF_OPEN; + halfOpenInFlight = 1; + return true; + } + return false; + + case HALF_OPEN: + default: + if (halfOpenInFlight < halfOpenTrials) + { + halfOpenInFlight++; + return true; + } + return false; + } + } + + + /** + * Record a successful call. + */ + synchronized void recordSuccess() + { + // Any success (in either Closed or Half-Open) restores normal operation. + reset(); + } + + + /** + * Release a half-open trial slot without judging backend health. Used when + * a call granted by {@link #allowRequest()} ends in a way that says nothing + * about whether the backend has recovered (e.g. an unexpected local + * error), so the slot becomes available for the next probe. + */ + synchronized void releaseTrial() + { + if (state == State.HALF_OPEN && halfOpenInFlight > 0) + { + halfOpenInFlight--; + } + } + + + /** + * Record a (transient) failure. + */ + synchronized void recordFailure() + { + long now = clock.getAsLong(); + + if (state == State.HALF_OPEN) + { + // The probe failed: reopen immediately. + trip(now); + return; + } + + // CLOSED: count failures within the rolling window. + if (!windowOpen || now - windowStart > windowMillis) + { + // Start a fresh window. + windowOpen = true; + windowStart = now; + failureCount = 0; + } + + failureCount++; + + if (failureCount >= failureThreshold) + { + trip(now); + } + } + + + private void trip(long now) + { + state = State.OPEN; + openedAt = now; + failureCount = 0; + windowOpen = false; + halfOpenInFlight = 0; + } + + + private void reset() + { + state = State.CLOSED; + failureCount = 0; + windowOpen = false; + halfOpenInFlight = 0; + } + + + synchronized State getState() + { + return state; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java new file mode 100644 index 0000000..1963471 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Lazily creates and holds one {@link AuthleteCircuitBreaker} per Authlete API method. + * + *

+ * Keeping breakers per method isolates failures: a breaker that has tripped for + * one endpoint (e.g. client management) does not affect another, higher-priority + * endpoint (e.g. introspection), as recommended by the "Isolate Resources" + * advice in Authlete's "Rate Limit Best Practices" guide. + *

+ */ +class AuthleteCircuitBreakerRegistry +{ + private final ConcurrentHashMap breakers = + new ConcurrentHashMap(); + + private final int failureThreshold; + private final long windowMillis; + private final long openMillis; + private final int halfOpenTrials; + + + AuthleteCircuitBreakerRegistry(ResilienceConfig config) + { + this.failureThreshold = config.getBreakerFailureThreshold(); + this.windowMillis = config.getBreakerWindowMillis(); + this.openMillis = config.getBreakerOpenMillis(); + this.halfOpenTrials = config.getBreakerHalfOpenTrials(); + } + + + /** + * Get the breaker for the given method name, creating it on first use. + */ + AuthleteCircuitBreaker forMethod(String methodName) + { + AuthleteCircuitBreaker existing = breakers.get(methodName); + + if (existing != null) + { + return existing; + } + + AuthleteCircuitBreaker created = + new AuthleteCircuitBreaker(failureThreshold, windowMillis, openMillis, halfOpenTrials); + + AuthleteCircuitBreaker previous = breakers.putIfAbsent(methodName, created); + + return (previous != null) ? previous : created; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java new file mode 100644 index 0000000..cd64007 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + + +/** + * A small, thread-safe, in-memory TTL cache for responses of idempotent + * Authlete API calls. + * + *

+ * Each entry has two lifetimes: + *

+ *
    + *
  • fresh — until its TTL elapses; {@link #getFresh(String)} + * returns it and the value is served directly without calling Authlete.
  • + *
  • stale — for an additional {@code staleMillis} after the TTL; + * {@link #getStale(String)} returns it. Stale values are used only as a + * fast-fail fallback while the circuit breaker is open, so that a degraded + * but functional response can be served during an outage.
  • + *
+ * + *

+ * The cache is intentionally dependency-free (a plain {@link ConcurrentHashMap}) + * so it is easy to read and copy. For cross-instance caching, replace the map + * with a shared store such as Redis. + *

+ * + *

+ * A single instance is shared across all cached methods; cache keys are + * namespaced by method (see {@link AuthleteCacheableMethods}) so there is no risk of + * collision between, say, a client id and a service-configuration request. + *

+ */ +class AuthleteResponseCache +{ + private static final class Entry + { + final Object value; + final long freshUntil; + final long staleUntil; + + Entry(Object value, long freshUntil, long staleUntil) + { + this.value = value; + this.freshUntil = freshUntil; + this.staleUntil = staleUntil; + } + } + + + private final ConcurrentHashMap map = new ConcurrentHashMap(); + private final long staleMillis; + private final int maxEntries; + private final LongSupplier clock; + + + AuthleteResponseCache(long staleMillis, int maxEntries) + { + this(staleMillis, maxEntries, System::currentTimeMillis); + } + + + /** + * Package-private constructor that allows an injected clock for testing. + */ + AuthleteResponseCache(long staleMillis, int maxEntries, LongSupplier clock) + { + this.staleMillis = staleMillis; + this.maxEntries = maxEntries; + this.clock = clock; + } + + + /** + * Return the cached value for the key only if it is still fresh, otherwise + * {@code null}. + */ + Object getFresh(String key) + { + Entry e = map.get(key); + + if (e == null) + { + return null; + } + + long now = clock.getAsLong(); + + if (now < e.freshUntil) + { + return e.value; + } + + // Expired beyond the stale window: drop it eagerly. + if (now >= e.staleUntil) + { + map.remove(key, e); + } + + return null; + } + + + /** + * Return the cached value for the key if it still exists within the stale + * window (whether fresh or only stale), otherwise {@code null}. + */ + Object getStale(String key) + { + Entry e = map.get(key); + + if (e == null) + { + return null; + } + + long now = clock.getAsLong(); + + if (now < e.staleUntil) + { + return e.value; + } + + map.remove(key, e); + + return null; + } + + + /** + * Store a value under the key with the given TTL (milliseconds). The stale + * window is added on top of the TTL. + */ + void put(String key, Object value, long ttlMillis) + { + if (value == null || ttlMillis <= 0) + { + return; + } + + long now = clock.getAsLong(); + + // Bound memory: evict expired entries first, then refuse new keys if + // still over the limit (existing keys are always allowed to refresh). + if (map.size() >= maxEntries && !map.containsKey(key)) + { + purgeExpired(now); + + if (map.size() >= maxEntries) + { + return; + } + } + + map.put(key, new Entry(value, now + ttlMillis, now + ttlMillis + staleMillis)); + } + + + private void purgeExpired(long now) + { + for (Map.Entry e : map.entrySet()) + { + if (now >= e.getValue().staleUntil) + { + map.remove(e.getKey(), e.getValue()); + } + } + } + + + /** + * Remove all entries. Exposed for completeness / testing. + */ + void clear() + { + map.clear(); + } + + + int size() + { + return map.size(); + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java new file mode 100644 index 0000000..e17bb08 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.List; +import java.util.Map; + + +/** + * Decides whether a failed Authlete API call is transient (worth + * retrying) or permanent (never retried), per Authlete's "Rate Limit + * Best Practices" guide. + * + *

+ * Transient failures are: + *

+ *
    + *
  • {@code 429 Too Many Requests} — retry only after the delay in the + * {@code RateLimit-Reset} response header (see + * {@link #rateLimitResetMillis(Map)}).
  • + *
  • {@code 502 Bad Gateway}, {@code 503 Service Unavailable}, and any other + * {@code 5xx}.
  • + *
  • Connection-level errors with no HTTP response (status {@code 0}).
  • + *
+ * + *

+ * Permanent failures are the remaining {@code 4xx} codes (e.g. {@code 400}, + * {@code 401}, {@code 403}); these can never succeed without changing the + * request, so they are surfaced immediately. + *

+ */ +class AuthleteRetryPolicy +{ + // Common spellings of the rate-limit reset header, matched case-insensitively. + private static final String[] RESET_HEADERS = { + "RateLimit-Reset", "Ratelimit-Reset", "X-RateLimit-Reset", "Retry-After" + }; + + + /** + * Tell whether the given HTTP status code denotes a transient failure that + * may be retried. + * + * @param statusCode + * The HTTP status code from {@code AuthleteApiException.getStatusCode()}. + * A value of {@code 0} means no HTTP response was received (a + * connection-level error), which is treated as transient. + */ + boolean isTransient(int statusCode) + { + // No HTTP response (connection refused, timeout, DNS failure, ...). + if (statusCode == 0) + { + return true; + } + + // Too Many Requests. + if (statusCode == 429) + { + return true; + } + + // Any server-side error. + if (statusCode >= 500 && statusCode < 600) + { + return true; + } + + // Everything else (notably 4xx) is permanent. + return false; + } + + + /** + * Extract the rate-limit reset delay (milliseconds) from the response + * headers, or {@code null} when no usable value is present. + * + *

+ * The header value is interpreted as a number of seconds to wait before the + * next attempt. Non-numeric or non-positive values are ignored. + *

+ */ + Long rateLimitResetMillis(Map> headers) + { + if (headers == null || headers.isEmpty()) + { + return null; + } + + for (String wanted : RESET_HEADERS) + { + String value = findHeader(headers, wanted); + + if (value == null) + { + continue; + } + + try + { + long seconds = Long.parseLong(value.trim()); + + if (seconds > 0) + { + return seconds * 1000L; + } + } + catch (NumberFormatException e) + { + // Try the next candidate header. + } + } + + return null; + } + + + private static String findHeader(Map> headers, String name) + { + for (Map.Entry> e : headers.entrySet()) + { + String key = e.getKey(); + + if (key != null && key.equalsIgnoreCase(name)) + { + List values = e.getValue(); + + if (values != null && !values.isEmpty()) + { + return values.get(0); + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java new file mode 100644 index 0000000..4209e58 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +/** + * Typed, defaulted access to the resilience configuration defined in + * {@code resilience.properties} (overridable via JVM system properties). + * + *

+ * Values are read once at class-load time. The defaults below match the + * recommendations in Authlete's "Rate Limit Best Practices" guide, so the + * resilience layer behaves sensibly even when {@code resilience.properties} + * is absent. + *

+ */ +public final class ResilienceConfig +{ + private static final ResilienceProperties PROPS = new ResilienceProperties(); + + // Master switch. + private final boolean enabled; + + // Cache. + private final boolean cacheEnabled; + private final long cacheTtlServiceConfiguration; + private final long cacheTtlServiceJwks; + private final long cacheTtlClient; + private final long cacheTtlCredentialIssuerMetadata; + private final long cacheTtlCredentialIssuerJwks; + private final long cacheTtlIntrospection; + private final long cacheTtlStandardIntrospection; + private final long cacheStaleMillis; + private final int cacheMaxEntries; + + // Retry / backoff. + private final boolean retryEnabled; + private final int retryMaxAttempts; + private final long retryBaseDelayMillis; + private final long retryMaxTotalMillis; + private final long retryJitterMillis; + + // Circuit breaker. + private final boolean breakerEnabled; + private final int breakerFailureThreshold; + private final long breakerWindowMillis; + private final long breakerOpenMillis; + private final int breakerHalfOpenTrials; + + + /** + * Build a configuration snapshot from {@code resilience.properties} and + * system properties. + */ + public ResilienceConfig() + { + enabled = PROPS.getBoolean("resilience.enabled", true); + + cacheEnabled = PROPS.getBoolean("resilience.cache.enabled", true); + cacheTtlServiceConfiguration = seconds("resilience.cache.ttl.serviceConfiguration", 600); + cacheTtlServiceJwks = seconds("resilience.cache.ttl.serviceJwks", 600); + cacheTtlClient = seconds("resilience.cache.ttl.client", 300); + cacheTtlCredentialIssuerMetadata = seconds("resilience.cache.ttl.credentialIssuerMetadata", 600); + cacheTtlCredentialIssuerJwks = seconds("resilience.cache.ttl.credentialIssuerJwks", 600); + cacheTtlIntrospection = seconds("resilience.cache.ttl.introspection", 30); + cacheTtlStandardIntrospection = seconds("resilience.cache.ttl.standardIntrospection", 30); + cacheStaleMillis = seconds("resilience.cache.staleSeconds", 1800); + cacheMaxEntries = PROPS.getInt("resilience.cache.maxEntries", 10000); + + retryEnabled = PROPS.getBoolean("resilience.retry.enabled", true); + retryMaxAttempts = PROPS.getInt("resilience.retry.maxAttempts", 4); + retryBaseDelayMillis = PROPS.getLong("resilience.retry.baseDelayMillis", 500); + retryMaxTotalMillis = PROPS.getLong("resilience.retry.maxTotalMillis", 60000); + retryJitterMillis = PROPS.getLong("resilience.retry.jitterMillis", 200); + + breakerEnabled = PROPS.getBoolean("resilience.breaker.enabled", true); + breakerFailureThreshold = PROPS.getInt("resilience.breaker.failureThreshold", 5); + breakerWindowMillis = seconds("resilience.breaker.windowSeconds", 30); + breakerOpenMillis = seconds("resilience.breaker.openSeconds", 60); + breakerHalfOpenTrials = PROPS.getInt("resilience.breaker.halfOpenTrials", 1); + } + + + /** + * Read a value expressed in seconds and return it in milliseconds. + */ + private static long seconds(String key, long defaultSeconds) + { + return PROPS.getLong(key, defaultSeconds) * 1000L; + } + + + public boolean isEnabled() + { + return enabled; + } + + + public boolean isCacheEnabled() + { + return cacheEnabled; + } + + + public long getCacheTtlServiceConfiguration() + { + return cacheTtlServiceConfiguration; + } + + + public long getCacheTtlServiceJwks() + { + return cacheTtlServiceJwks; + } + + + public long getCacheTtlClient() + { + return cacheTtlClient; + } + + + public long getCacheTtlCredentialIssuerMetadata() + { + return cacheTtlCredentialIssuerMetadata; + } + + + public long getCacheTtlCredentialIssuerJwks() + { + return cacheTtlCredentialIssuerJwks; + } + + + public long getCacheTtlIntrospection() + { + return cacheTtlIntrospection; + } + + + public long getCacheTtlStandardIntrospection() + { + return cacheTtlStandardIntrospection; + } + + + public long getCacheStaleMillis() + { + return cacheStaleMillis; + } + + + public int getCacheMaxEntries() + { + return cacheMaxEntries; + } + + + public boolean isRetryEnabled() + { + return retryEnabled; + } + + + public int getRetryMaxAttempts() + { + return retryMaxAttempts; + } + + + public long getRetryBaseDelayMillis() + { + return retryBaseDelayMillis; + } + + + public long getRetryMaxTotalMillis() + { + return retryMaxTotalMillis; + } + + + public long getRetryJitterMillis() + { + return retryJitterMillis; + } + + + public boolean isBreakerEnabled() + { + return breakerEnabled; + } + + + public int getBreakerFailureThreshold() + { + return breakerFailureThreshold; + } + + + public long getBreakerWindowMillis() + { + return breakerWindowMillis; + } + + + public long getBreakerOpenMillis() + { + return breakerOpenMillis; + } + + + public int getBreakerHalfOpenTrials() + { + return breakerHalfOpenTrials; + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java new file mode 100644 index 0000000..ef07880 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.util.MissingResourceException; +import java.util.ResourceBundle; +import com.authlete.jaxrs.server.util.TypedSystemProperties; + + +/** + * Reads resilience configuration from {@code resilience.properties} (on the + * classpath) with JVM system properties taking precedence. + * + *

+ * This mirrors {@link com.authlete.jaxrs.server.util.ServerProperties + * ServerProperties} but binds to the dedicated {@code resilience} resource + * bundle so that resilience tuning lives in its own file, separate from the + * server's functional configuration. + *

+ * + * @see ResilienceConfig + */ +class ResilienceProperties extends TypedSystemProperties +{ + private static final ResourceBundle RESOURCE_BUNDLE; + + + static + { + ResourceBundle bundle = null; + + try + { + bundle = ResourceBundle.getBundle("resilience"); + } + catch (MissingResourceException mre) + { + // The file is optional; built-in defaults will be used instead. + } + + RESOURCE_BUNDLE = bundle; + } + + + @Override + public String getString(String key, String defaultValue) + { + if (key == null) + { + return defaultValue; + } + + // A JVM system property always wins over the file. + if (super.contains(key)) + { + return super.getString(key, defaultValue); + } + + // The properties file is not available. + if (RESOURCE_BUNDLE == null) + { + return defaultValue; + } + + try + { + return RESOURCE_BUNDLE.getString(key); + } + catch (MissingResourceException e) + { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java new file mode 100644 index 0000000..fcb3b4c --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.lang.reflect.Proxy; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiFactory; + + +/** + * Drop-in replacement for {@link AuthleteApiFactory#getDefaultApi()} that + * returns an {@link AuthleteApi} wrapped with the resilience layer (caching, + * conditional retry, exponential backoff with jitter, and a circuit breaker). + * + *

+ * Endpoint classes obtain their API client through this factory instead of + * {@code AuthleteApiFactory} so that every call to Authlete — including + * nested calls made by handler SPIs that receive the same instance — is + * protected, with no change to the endpoint logic itself. + *

+ * + *

+ * The wrapped instance is built once and shared. Tuning lives in + * {@code resilience.properties} (see {@link ResilienceConfig}); when resilience + * is disabled there, the underlying default {@code AuthleteApi} is returned + * unwrapped, restoring the original behaviour. + *

+ */ +public final class ResilientAuthleteApiFactory +{ + private static volatile AuthleteApi cachedApi; + + + private ResilientAuthleteApiFactory() + { + } + + + /** + * Get the resilient default {@link AuthleteApi} instance. + * + * @return + * The default {@code AuthleteApi} wrapped with the resilience layer, + * or the unwrapped default instance when resilience is disabled. + */ + public static AuthleteApi getDefaultApi() + { + AuthleteApi api = cachedApi; + + if (api != null) + { + return api; + } + + return initDefaultApi(); + } + + + private static synchronized AuthleteApi initDefaultApi() + { + if (cachedApi != null) + { + return cachedApi; + } + + AuthleteApi delegate = AuthleteApiFactory.getDefaultApi(); + + cachedApi = wrap(delegate); + + return cachedApi; + } + + + /** + * Wrap the given {@link AuthleteApi} with the resilience layer. Returns the + * delegate unchanged when resilience is disabled in the configuration. + */ + public static AuthleteApi wrap(AuthleteApi delegate) + { + ResilienceConfig config = new ResilienceConfig(); + + if (!config.isEnabled()) + { + return delegate; + } + + return (AuthleteApi) Proxy.newProxyInstance( + AuthleteApi.class.getClassLoader(), + new Class[] { AuthleteApi.class }, + new ResilientAuthleteApiInvocationHandler(delegate, config)); + } +} diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java new file mode 100644 index 0000000..209d395 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiException; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.jaxrs.server.resilience.AuthleteCacheableMethods.CachePolicy; + + +/** + * The {@link InvocationHandler} behind the resilient {@link AuthleteApi} proxy. + * + *

+ * Every call to the wrapped {@code AuthleteApi} flows through {@link #invoke} + * which applies, in order, the four practices from Authlete's "Rate Limit Best + * Practices" guide: + *

+ *
    + *
  1. Caching — a fresh cached response for an idempotent read is + * returned without calling Authlete.
  2. + *
  3. Circuit breaking — when the per-method breaker is open, the + * call fails fast, serving stale cached data when available.
  4. + *
  5. Conditional retry — only transient failures (429/5xx/no + * response) are retried; permanent 4xx errors propagate immediately.
  6. + *
  7. Exponential backoff with jitter — the wait before each + * retry grows exponentially (honouring {@code RateLimit-Reset} on 429), + * bounded by a total retry budget.
  8. + *
+ */ +class ResilientAuthleteApiInvocationHandler implements InvocationHandler +{ + private static final Logger logger = + LoggerFactory.getLogger(ResilientAuthleteApiInvocationHandler.class); + + private final AuthleteApi delegate; + private final AuthleteCacheableMethods cacheable; + private final AuthleteResponseCache cache; + private final AuthleteRetryPolicy retry; + private final AuthleteBackoff backoff; + private final AuthleteCircuitBreakerRegistry breakers; + + private final boolean cacheEnabled; + private final boolean retryEnabled; + private final boolean breakerEnabled; + private final int maxAttempts; + private final long maxTotalMillis; + + + ResilientAuthleteApiInvocationHandler(AuthleteApi delegate, ResilienceConfig config) + { + this.delegate = delegate; + this.cacheable = new AuthleteCacheableMethods(config); + this.cache = new AuthleteResponseCache(config.getCacheStaleMillis(), config.getCacheMaxEntries()); + this.retry = new AuthleteRetryPolicy(); + this.backoff = new AuthleteBackoff( + config.getRetryBaseDelayMillis(), + config.getRetryMaxTotalMillis(), + config.getRetryJitterMillis()); + this.breakers = new AuthleteCircuitBreakerRegistry(config); + + this.cacheEnabled = config.isCacheEnabled(); + this.retryEnabled = config.isRetryEnabled(); + this.breakerEnabled = config.isBreakerEnabled(); + this.maxAttempts = Math.max(1, config.getRetryMaxAttempts()); + this.maxTotalMillis = config.getRetryMaxTotalMillis(); + } + + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable + { + // Methods inherited from Object (equals/hashCode/toString) are handled + // locally and never forwarded to Authlete. + if (method.getDeclaringClass() == Object.class) + { + return invokeObjectMethod(proxy, method, args); + } + + CachePolicy policy = cacheEnabled ? cacheable.policyFor(method, args) : null; + + // (1) Serve a fresh cached response without touching the network. + if (policy != null) + { + Object fresh = cache.getFresh(policy.key); + + if (fresh != null) + { + return fresh; + } + } + + AuthleteCircuitBreaker breaker = breakerEnabled ? breakers.forMethod(method.getName()) : null; + + long start = System.currentTimeMillis(); + int attempt = 0; + AuthleteApiException lastError = null; + + while (true) + { + attempt++; + + // (2) Circuit breaker gate: fail fast when open. + if (breaker != null && !breaker.allowRequest()) + { + Object stale = serveStale(policy, method, "circuit open"); + + if (stale != null) + { + return stale; + } + + throw (lastError != null) ? lastError : circuitOpenException(method); + } + + try + { + Object result = method.invoke(delegate, args); + + if (breaker != null) + { + breaker.recordSuccess(); + } + + if (policy != null) + { + cache.put(policy.key, result, effectiveTtl(policy, result)); + } + + return result; + } + catch (InvocationTargetException ite) + { + Throwable cause = ite.getCause(); + + // Only AuthleteApiException participates in retry/breaker logic; + // anything else is an unexpected error and is propagated as-is, + // after releasing any half-open trial slot reserved by + // allowRequest() (the error says nothing about backend health). + if (!(cause instanceof AuthleteApiException)) + { + if (breaker != null) + { + breaker.releaseTrial(); + } + + throw (cause != null) ? cause : ite; + } + + AuthleteApiException ae = (AuthleteApiException) cause; + lastError = ae; + + int status = ae.getStatusCode(); + boolean isTransient = retry.isTransient(status); + + // (3) Only transient failures count toward the breaker. A + // permanent 4xx proves the backend is up and answering, so it + // counts as a success (closing a half-open breaker and + // releasing the trial slot). + if (breaker != null) + { + if (isTransient) + { + breaker.recordFailure(); + } + else + { + breaker.recordSuccess(); + } + } + + // (4) Retry transient failures with exponential backoff, within budget. + if (retryEnabled && isTransient && attempt < maxAttempts) + { + Long reset = (status == 429) + ? retry.rateLimitResetMillis(ae.getResponseHeaders()) : null; + + long delay = backoff.delayMillis(attempt, reset); + long elapsed = System.currentTimeMillis() - start; + + if (elapsed + delay <= maxTotalMillis) + { + logger.debug("Authlete API {} failed (status={}, attempt={}); retrying in {} ms.", + method.getName(), status, attempt, delay); + + if (sleep(delay)) + { + continue; + } + } + } + + // Exhausted retries (or permanent error): try a stale fallback for + // transient failures, otherwise surface the original exception. + if (isTransient) + { + Object stale = serveStale(policy, method, "transient failure, retries exhausted"); + + if (stale != null) + { + return stale; + } + } + + throw ae; + } + } + } + + + /** + * Compute the TTL to store a freshly fetched value under, capping + * introspection results so a cached entry never reports a token as active + * past its own expiry. + */ + private long effectiveTtl(CachePolicy policy, Object result) + { + long ttl = policy.ttlMillis; + + if (policy.capByTokenExpiry && result instanceof IntrospectionResponse) + { + long expiresAt = ((IntrospectionResponse) result).getExpiresAt(); + + if (expiresAt > 0) + { + long untilExpiry = expiresAt - System.currentTimeMillis(); + + // Already expired: do not cache at all. + if (untilExpiry <= 0) + { + return 0; + } + + ttl = Math.min(ttl, untilExpiry); + } + } + + return ttl; + } + + + private Object serveStale(CachePolicy policy, Method method, String reason) + { + if (policy == null) + { + return null; + } + + Object stale = cache.getStale(policy.key); + + if (stale != null) + { + logger.warn("Serving stale cached response for Authlete API {} ({}).", + method.getName(), reason); + } + + return stale; + } + + + /** + * Sleep for the given duration. Returns {@code false} if interrupted, in + * which case the caller should stop retrying. + */ + private boolean sleep(long millis) + { + if (millis <= 0) + { + return true; + } + + try + { + Thread.sleep(millis); + return true; + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return false; + } + } + + + private static AuthleteApiException circuitOpenException(Method method) + { + return new AuthleteApiException( + "Circuit breaker is open for Authlete API '" + method.getName() + + "'; failing fast to protect the service.", + 503, "Service Unavailable", null); + } + + + private Object invokeObjectMethod(Object proxy, Method method, Object[] args) + { + switch (method.getName()) + { + case "equals": + return proxy == args[0]; + + case "hashCode": + return System.identityHashCode(proxy); + + case "toString": + default: + return "ResilientAuthleteApi[" + delegate + "]"; + } + } +} \ No newline at end of file diff --git a/src/main/resources/resilience.properties b/src/main/resources/resilience.properties new file mode 100644 index 0000000..b77c2ab --- /dev/null +++ b/src/main/resources/resilience.properties @@ -0,0 +1,77 @@ +# +# Resilience configuration for calls made to Authlete's API endpoints. +# +# This file tunes the resilience layer that wraps the Authlete API client +# (see com.authlete.jaxrs.server.resilience). The layer implements the +# practices described in Authlete's "Rate Limit Best Practices" guide: +# intelligent caching of idempotent reads, conditional retries on transient +# failures, exponential backoff with jitter, and a circuit breaker. +# +# Every key below can also be overridden with a JVM system property of the +# same name (e.g. -Dresilience.cache.enabled=false), which always wins over +# the value defined here. All durations are expressed in the unit noted on +# each key. Removing a key simply falls back to the built-in default shown +# in ResilienceConfig. +# + +# --------------------------------------------------------------------------- +# Master switch. When false, the Authlete API client is used directly with no +# caching, retries or circuit breaking (i.e. the original behaviour). +# --------------------------------------------------------------------------- +resilience.enabled = true + +# --------------------------------------------------------------------------- +# Caching of idempotent (safe, repeatable) read endpoints. TTLs are in seconds. +# Only the endpoints listed in the best-practices guide are cached; all other +# API calls are never cached. Keep the introspection TTLs short: a cached +# introspection result keeps reporting a token as active until the entry +# expires, even if the token was revoked in the meantime. +# --------------------------------------------------------------------------- +resilience.cache.enabled = true +resilience.cache.ttl.serviceConfiguration = 600 +resilience.cache.ttl.serviceJwks = 600 +resilience.cache.ttl.client = 300 +resilience.cache.ttl.credentialIssuerMetadata = 600 +resilience.cache.ttl.credentialIssuerJwks = 600 +resilience.cache.ttl.introspection = 30 +resilience.cache.ttl.standardIntrospection = 30 + +# How long an expired entry is retained as "stale" (seconds). Stale entries are +# never served during normal operation; they are only used as a fast-fail +# fallback while the circuit breaker for that endpoint is open. +resilience.cache.staleSeconds = 1800 + +# Safety cap on the number of cached entries (per cached method) to bound memory. +resilience.cache.maxEntries = 10000 + +# --------------------------------------------------------------------------- +# Conditional retry with exponential backoff and jitter. Retries are attempted +# only for transient failures: HTTP 429, 502, 503, any other 5xx, and +# connection-level errors (no HTTP response). Permanent 4xx errors +# (400/401/403/...) are never retried. +# --------------------------------------------------------------------------- +resilience.retry.enabled = true +# Total number of attempts, including the first one (so 4 means 1 try + 3 retries). +resilience.retry.maxAttempts = 4 +# Base delay for the first retry (ms). Subsequent retries double it: 500, 1000, 2000, ... +resilience.retry.baseDelayMillis = 500 +# Hard cap on the total time spent retrying a single call (ms). +resilience.retry.maxTotalMillis = 60000 +# Upper bound of the random jitter added to each backoff delay (ms). +resilience.retry.jitterMillis = 200 + +# --------------------------------------------------------------------------- +# Circuit breaker. One independent breaker is kept per Authlete API method, so +# a storm of failures on (say) client management does not trip the breaker for +# introspection. While a breaker is open, calls fail fast (and serve stale +# cached data when available) instead of hammering an unhealthy backend. +# --------------------------------------------------------------------------- +resilience.breaker.enabled = true +# Number of transient failures within the window that trips the breaker open. +resilience.breaker.failureThreshold = 5 +# Rolling window (seconds) over which failures are counted. +resilience.breaker.windowSeconds = 30 +# How long the breaker stays open before allowing trial requests (seconds). +resilience.breaker.openSeconds = 60 +# Number of trial requests allowed while half-open before deciding to close/reopen. +resilience.breaker.halfOpenTrials = 1 \ No newline at end of file diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java new file mode 100644 index 0000000..c776ff7 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import java.util.Random; +import org.junit.Test; + + +public class AuthleteBackoffTest +{ + @Test + public void exponentialScheduleWithoutJitter() + { + // base=500, max=60000, jitter=0 + AuthleteBackoff backoff = new AuthleteBackoff(500, 60000, 0, new Random(0)); + + assertEquals(500, backoff.delayMillis(1, null)); // 500 * 2^0 + assertEquals(1000, backoff.delayMillis(2, null)); // 500 * 2^1 + assertEquals(2000, backoff.delayMillis(3, null)); // 500 * 2^2 + assertEquals(4000, backoff.delayMillis(4, null)); // 500 * 2^3 + } + + + @Test + public void delayIsCappedAtMax() + { + AuthleteBackoff backoff = new AuthleteBackoff(500, 3000, 0, new Random(0)); + + // 500 * 2^3 = 4000 would exceed the 3000 cap. + assertEquals(3000, backoff.delayMillis(4, null)); + assertEquals(3000, backoff.delayMillis(20, null)); + } + + + @Test + public void explicitDelayIsHonoured() + { + AuthleteBackoff backoff = new AuthleteBackoff(500, 60000, 0, new Random(0)); + + // A RateLimit-Reset of 5s overrides the exponential value. + assertEquals(5000, backoff.delayMillis(1, 5000L)); + } + + + @Test + public void jitterStaysWithinBounds() + { + long base = 500; + long jitter = 200; + AuthleteBackoff backoff = new AuthleteBackoff(base, 60000, jitter, new Random(42)); + + for (int i = 0; i < 100; i++) + { + long delay = backoff.delayMillis(1, null); + assertTrue("delay >= base", delay >= base); + assertTrue("delay < base + jitter", delay < base + jitter); + } + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java new file mode 100644 index 0000000..81b60c7 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import java.lang.reflect.Method; +import java.net.URI; +import org.junit.Test; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.StandardIntrospectionRequest; +import com.authlete.jaxrs.server.resilience.AuthleteCacheableMethods.CachePolicy; + + +public class AuthleteCacheableMethodsTest +{ + private final AuthleteCacheableMethods cacheable = + new AuthleteCacheableMethods(new ResilienceConfig()); + + + private CachePolicy introspectionPolicy(IntrospectionRequest req) throws Exception + { + Method method = AuthleteApi.class.getMethod( + "introspection", IntrospectionRequest.class); + + return cacheable.policyFor(method, new Object[] { req }); + } + + + private CachePolicy standardIntrospectionPolicy(StandardIntrospectionRequest req) throws Exception + { + Method method = AuthleteApi.class.getMethod( + "standardIntrospection", StandardIntrospectionRequest.class); + + return cacheable.policyFor(method, new Object[] { req }); + } + + + @Test + public void dpopIntrospectionIsNeverCached() throws Exception + { + IntrospectionRequest req = new IntrospectionRequest() + .setToken("token") + .setDpop("dpop-proof") + .setHtm("GET") + .setHtu("https://rs.example.com/resource"); + + assertNull("DPoP-bound requests must not be cached", + introspectionPolicy(req)); + } + + + @Test + public void messageSignatureIntrospectionIsNeverCached() throws Exception + { + IntrospectionRequest req = new IntrospectionRequest() + .setToken("token") + .setMessage("signed-message"); + + assertNull("message-signature requests must not be cached", + introspectionPolicy(req)); + } + + + @Test + public void clientCertificateParticipatesInIntrospectionKey() throws Exception + { + IntrospectionRequest base = new IntrospectionRequest().setToken("token"); + + CachePolicy noCert = introspectionPolicy(base); + CachePolicy withCert = introspectionPolicy( + new IntrospectionRequest().setToken("token").setClientCertificate("CERT-A")); + CachePolicy otherCert = introspectionPolicy( + new IntrospectionRequest().setToken("token").setClientCertificate("CERT-B")); + + assertNotNull(noCert); + assertNotNull(withCert); + assertNotNull(otherCert); + assertNotEquals("same token, different certs must not share an entry", + withCert.key, otherCert.key); + assertNotEquals(noCert.key, withCert.key); + } + + + @Test + public void resourcesParticipateInIntrospectionKey() throws Exception + { + CachePolicy a = introspectionPolicy(new IntrospectionRequest() + .setToken("token") + .setResources(new URI[] { URI.create("https://rs-a.example.com") })); + CachePolicy b = introspectionPolicy(new IntrospectionRequest() + .setToken("token") + .setResources(new URI[] { URI.create("https://rs-b.example.com") })); + + assertNotEquals(a.key, b.key); + } + + + @Test + public void rsUriParticipatesInStandardIntrospectionKey() throws Exception + { + CachePolicy a = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setRsUri(URI.create("https://rs-a.example.com"))); + CachePolicy b = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setRsUri(URI.create("https://rs-b.example.com"))); + + assertNotEquals("different resource servers must not share an entry", + a.key, b.key); + } + + + @Test + public void acceptHeaderParticipatesInStandardIntrospectionKey() throws Exception + { + CachePolicy json = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setHttpAcceptHeader("application/json")); + CachePolicy jwt = standardIntrospectionPolicy(new StandardIntrospectionRequest() + .setParameters("token=abc") + .setHttpAcceptHeader("application/token-introspection+jwt")); + + assertNotEquals(json.key, jwt.key); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java new file mode 100644 index 0000000..dc61329 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import java.util.function.LongSupplier; +import org.junit.Test; +import com.authlete.jaxrs.server.resilience.AuthleteCircuitBreaker.State; + + +public class AuthleteCircuitBreakerTest +{ + /** A clock whose value the test advances manually. */ + private final long[] now = { 0L }; + private final LongSupplier clock = () -> now[0]; + + + private AuthleteCircuitBreaker newBreaker() + { + // threshold=3 failures within a 30s window, open for 60s, 1 half-open trial. + return new AuthleteCircuitBreaker(3, 30_000, 60_000, 1, clock); + } + + + @Test + public void opensAfterThresholdFailures() + { + AuthleteCircuitBreaker cb = newBreaker(); + + assertTrue(cb.allowRequest()); + cb.recordFailure(); + cb.recordFailure(); + assertEquals(State.CLOSED, cb.getState()); + + cb.recordFailure(); // 3rd failure trips it open + assertEquals(State.OPEN, cb.getState()); + assertFalse("open breaker fails fast", cb.allowRequest()); + } + + + @Test + public void successResetsFailureCount() + { + AuthleteCircuitBreaker cb = newBreaker(); + + cb.recordFailure(); + cb.recordFailure(); + cb.recordSuccess(); // resets the count + cb.recordFailure(); + cb.recordFailure(); + + assertEquals("still closed after reset", State.CLOSED, cb.getState()); + } + + + @Test + public void failuresOutsideWindowDoNotAccumulate() + { + AuthleteCircuitBreaker cb = newBreaker(); + + cb.recordFailure(); + cb.recordFailure(); + + // Advance beyond the 30s rolling window: the count restarts. + now[0] += 31_000; + cb.recordFailure(); + cb.recordFailure(); + + assertEquals(State.CLOSED, cb.getState()); + } + + + @Test + public void halfOpenSuccessClosesBreaker() + { + AuthleteCircuitBreaker cb = newBreaker(); + trip(cb); + + // Before the open timeout, requests are rejected. + now[0] += 30_000; + assertFalse(cb.allowRequest()); + + // After the open timeout, a single trial is allowed (half-open). + now[0] += 31_000; + assertTrue("half-open trial allowed", cb.allowRequest()); + assertEquals(State.HALF_OPEN, cb.getState()); + assertFalse("only one trial permitted", cb.allowRequest()); + + cb.recordSuccess(); + assertEquals("success closes the breaker", State.CLOSED, cb.getState()); + } + + + @Test + public void halfOpenFailureReopensBreaker() + { + AuthleteCircuitBreaker cb = newBreaker(); + trip(cb); + + now[0] += 61_000; + assertTrue(cb.allowRequest()); // half-open trial + cb.recordFailure(); + + assertEquals("failed trial reopens", State.OPEN, cb.getState()); + assertFalse(cb.allowRequest()); + } + + + @Test + public void releaseTrialFreesHalfOpenSlot() + { + AuthleteCircuitBreaker cb = newBreaker(); + trip(cb); + + now[0] += 61_000; + assertTrue(cb.allowRequest()); // half-open trial reserved + assertFalse("slot taken", cb.allowRequest()); + + // The trial ended without a verdict on backend health (e.g. an + // unexpected local error): the slot must become available again. + cb.releaseTrial(); + assertEquals("still half-open", State.HALF_OPEN, cb.getState()); + assertTrue("slot available again", cb.allowRequest()); + } + + + private void trip(AuthleteCircuitBreaker cb) + { + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + assertEquals(State.OPEN, cb.getState()); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java new file mode 100644 index 0000000..54cdc2f --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import java.util.function.LongSupplier; +import org.junit.Test; + + +public class AuthleteResponseCacheTest +{ + private final long[] now = { 0L }; + private final LongSupplier clock = () -> now[0]; + + + @Test + public void freshHitWithinTtl() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("k", "v", 500); + + now[0] = 499; + assertEquals("v", cache.getFresh("k")); + } + + + @Test + public void expiresAfterTtlButStaleRemains() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("k", "v", 500); // fresh until 500, stale until 1500 + + now[0] = 600; + assertNull("no longer fresh", cache.getFresh("k")); + assertEquals("but available as stale", "v", cache.getStale("k")); + } + + + @Test + public void staleEntryDroppedAfterStaleWindow() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("k", "v", 500); // stale until 1500 + + now[0] = 1600; + assertNull(cache.getStale("k")); + assertNull(cache.getFresh("k")); + } + + + @Test + public void nullValueAndNonPositiveTtlAreNotCached() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("a", null, 500); + cache.put("b", "v", 0); + + assertNull(cache.getFresh("a")); + assertNull(cache.getFresh("b")); + assertEquals(0, cache.size()); + } + + + @Test + public void maxEntriesIsBounded() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 2, clock); + cache.put("a", "1", 500); + cache.put("b", "2", 500); + cache.put("c", "3", 500); // exceeds the limit; rejected while others are live + + assertEquals(2, cache.size()); + assertNull("new key rejected at capacity", cache.getFresh("c")); + + // Updating an existing key is still allowed at capacity. + cache.put("a", "1b", 500); + assertEquals("1b", cache.getFresh("a")); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java new file mode 100644 index 0000000..8d2fdc3 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Authlete, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package com.authlete.jaxrs.server.resilience; + + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + + +public class AuthleteRetryPolicyTest +{ + private final AuthleteRetryPolicy policy = new AuthleteRetryPolicy(); + + + @Test + public void transientStatuses() + { + assertTrue("no response is transient", policy.isTransient(0)); + assertTrue("429 is transient", policy.isTransient(429)); + assertTrue("500 is transient", policy.isTransient(500)); + assertTrue("502 is transient", policy.isTransient(502)); + assertTrue("503 is transient", policy.isTransient(503)); + assertTrue("599 is transient", policy.isTransient(599)); + } + + + @Test + public void permanentStatuses() + { + assertFalse("400 is permanent", policy.isTransient(400)); + assertFalse("401 is permanent", policy.isTransient(401)); + assertFalse("403 is permanent", policy.isTransient(403)); + assertFalse("404 is permanent", policy.isTransient(404)); + assertFalse("200 is not retried", policy.isTransient(200)); + } + + + @Test + public void rateLimitResetParsedFromSeconds() + { + Map> headers = new HashMap<>(); + headers.put("RateLimit-Reset", Collections.singletonList("3")); + + assertEquals(Long.valueOf(3000L), policy.rateLimitResetMillis(headers)); + } + + + @Test + public void rateLimitResetHeaderIsCaseInsensitive() + { + Map> headers = new HashMap<>(); + headers.put("ratelimit-reset", Arrays.asList("2")); + + assertEquals(Long.valueOf(2000L), policy.rateLimitResetMillis(headers)); + } + + + @Test + public void rateLimitResetAbsentOrInvalid() + { + assertNull(policy.rateLimitResetMillis(null)); + assertNull(policy.rateLimitResetMillis(new HashMap<>())); + + Map> bad = new HashMap<>(); + bad.put("RateLimit-Reset", Collections.singletonList("not-a-number")); + assertNull(policy.rateLimitResetMillis(bad)); + + Map> zero = new HashMap<>(); + zero.put("RateLimit-Reset", Collections.singletonList("0")); + assertNull("non-positive reset is ignored", policy.rateLimitResetMillis(zero)); + } +}