diff --git a/pom.xml b/pom.xml index 30638a6..429a158 100644 --- a/pom.xml +++ b/pom.xml @@ -170,6 +170,14 @@ gson 2.14.0 + + + + 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 0d67341..fc026ca 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 jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.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 b798cdd..2ac45d7 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jakarta.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 0aa6dd2..665aef9 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 jakarta.ws.rs.core.Response; import jakarta.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.jakarta.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 bfed47f..78d70e7 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 jakarta.ws.rs.QueryParam; import jakarta.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.jakarta.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 a28e3d1..3fa2186 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 jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.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.jakarta.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 3880b29..5e1fcad 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 jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.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.jakarta.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 6b6efda..4f11027 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 jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jakarta.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 b487385..27f1299 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import jakarta.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.jakarta.BaseIntrospectionEndpoint; import com.authlete.jakarta.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 66517f5..34a55ae 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 jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jakarta.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 86e5226..3d8a92d 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.BasePushedAuthReqEndpoint; import com.authlete.jakarta.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 4289f5e..71c0ba8 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.BaseRevocationEndpoint; import com.authlete.jakarta.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 8f58ebd..8a4dc66 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.BaseTokenEndpoint; import com.authlete.jakarta.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 d7c3145..ae42050 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 jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import com.authlete.common.api.AuthleteApiFactory; +import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory; import com.authlete.jakarta.BaseUserInfoEndpoint; import com.authlete.jakarta.UserInfoRequestHandler.Params; import com.authlete.jakarta.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 c5da57d..0dc16e2 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 jakarta.ws.rs.Path; import jakarta.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.jakarta.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 6d534e1..c39cfbb 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 jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.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.jakarta.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/BackchannelAuthenticationCompleteHandlerSpiImpl.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCompleteHandlerSpiImpl.java index 717b91c..11f825b 100644 --- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCompleteHandlerSpiImpl.java +++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCompleteHandlerSpiImpl.java @@ -228,7 +228,7 @@ private Response doSendNotification(URI clientNotificationEndpointUri, try { - // Send the notification to the consumption device.. + // Send the notification to the consumption device. return webClient.target(clientNotificationEndpointUri).request() // CIBA Core says "The OP MUST NOT follow redirects." .property(ClientProperties.FOLLOW_REDIRECTS, Boolean.FALSE) @@ -267,9 +267,10 @@ private Client createClient() { // SSLContext's for older TLS versions ("TLSv1" and "TLSv1.1") may not // include any FAPI cipher suites. Here we create an SSLContext with - // "TLSv1.2" whose getDefaultSSLParameters().getCipherSuites() probably - // includes FAPI cipher suites. - SSLContext sc = createSslContext("TLSv1.2"); + // "TLSv1.3" that includes the required version 1.2 and the recommended + // 1.3 version. Both of whose getDefaultSSLParameters().getCipherSuites() + // should include FAPI compatible cipher suites. + SSLContext sc = createSslContext("TLSv1.3"); return ClientBuilder.newBuilder().sslContext(sc).build(); } @@ -293,4 +294,4 @@ private SSLContext createSslContext(String protocol) "Failed to get an SSLContext for " + protocol + ": " + e.getMessage()); } } -} +} \ No newline at end of file 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 c518f65..ecd323c 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.BackchannelAuthenticationRequestHandler.Params; import com.authlete.jakarta.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 e2206fd..218fa68 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 e39bb57..0ea86ab 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 jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.BaseDeviceAuthorizationEndpoint; import com.authlete.jakarta.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 58d011c..93b0e21 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 jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.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.jakarta.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 cb9f3ab..76e44c3 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 jakarta.ws.rs.core.Response; import jakarta.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.jakarta.BaseDeviceVerificationEndpoint; import com.authlete.jakarta.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..44e1bbc --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java @@ -0,0 +1,271 @@ +/* + * 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 + // never share an entry. Parameters that only apply to DPoP or HTTP + // message signature requests (htm, htu, targetUri, uri, + // dpopNonceRequired, requestBodyContained) are intentionally omitted: + // those requests are excluded above, so such parameters do not affect + // the result of a cacheable request. + 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(join(req.getResources())); + detail.append('|').append(join(req.getAcrValues())); + detail.append('|').append(req.getMaxAge()); + + 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..e25cb47 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java @@ -0,0 +1,233 @@ +/* + * 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; +import java.util.function.Predicate; + + +/** + * 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 every entry whose key satisfies the given predicate. + * + *

+ * Used for best-effort eviction of a revoked token's introspection + * entries: introspection cache keys start with the token (see + * {@link AuthleteCacheableMethods}), so all entries for one token can be + * matched without a reverse index. + *

+ * + * @return + * The number of entries removed. + */ + int removeIf(Predicate keyPredicate) + { + int removed = 0; + + for (String key : map.keySet()) + { + if (keyPredicate.test(key) && map.remove(key) != null) + { + removed++; + } + } + + return removed; + } + + + /** + * 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..9831710 --- /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", "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..dd82f68 --- /dev/null +++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java @@ -0,0 +1,420 @@ +/* + * 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.io.UnsupportedEncodingException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URLDecoder; +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.common.dto.RevocationRequest; +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)); + } + + // Best-effort local eviction: once a revocation goes through, + // this instance must stop serving cached introspection results + // that still report the token as active. + if (cacheEnabled && "revocation".equals(method.getName())) + { + evictIntrospectionEntriesForRevokedToken(args); + } + + 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; + } + + + /** + * Drop every cached introspection entry for the token named in a + * successful revocation request, so this instance stops reporting the + * token as active right away instead of waiting for the TTL. + * + *

+ * Introspection cache keys start with the raw token, so a prefix match + * finds all of them; standard-introspection keys embed the token inside + * the form parameters, so a contains match is used there. This is + * best-effort and local only: other instances still rely on the short + * TTL, and a refresh-token revocation cannot evict the access tokens + * Authlete revokes alongside it. + *

+ */ + private void evictIntrospectionEntriesForRevokedToken(Object[] args) + { + String token = revokedToken(args); + + if (token == null || token.isEmpty()) + { + return; + } + + String prefix = "introspection::" + token + "|"; + + int removed = cache.removeIf(key -> + key.startsWith(prefix) + || (key.startsWith("standardIntrospection::") && key.contains(token))); + + if (removed > 0) + { + logger.debug("Evicted {} cached introspection entries for a revoked token.", removed); + } + } + + + /** + * Extract the {@code token} request parameter from the revocation + * request's form-encoded parameters, or {@code null} if absent. + */ + private static String revokedToken(Object[] args) + { + if (args == null || args.length == 0 || !(args[0] instanceof RevocationRequest)) + { + return null; + } + + String parameters = ((RevocationRequest) args[0]).getParameters(); + + if (parameters == null) + { + return null; + } + + for (String pair : parameters.split("&")) + { + int eq = pair.indexOf('='); + + if (eq < 0 || !"token".equals(pair.substring(0, eq))) + { + continue; + } + + String value = pair.substring(eq + 1); + + try + { + return URLDecoder.decode(value, "UTF-8"); + } + catch (UnsupportedEncodingException e) + { + // UTF-8 is always supported; fall back to the raw value. + return value; + } + } + + return null; + } + + + 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..7d80851 --- /dev/null +++ b/src/main/resources/resilience.properties @@ -0,0 +1,79 @@ +# +# 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. A revocation processed by this instance evicts +# its own cached introspection entries for that token immediately (best-effort, +# local only). Keep the introspection TTLs short anyway: other instances, and +# tokens revoked indirectly (e.g. access tokens invalidated by revoking their +# refresh token), still rely on the TTL to stop reporting a token as active. +# --------------------------------------------------------------------------- +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..9d85879 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java @@ -0,0 +1,113 @@ +/* + * 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")); + } + + + @Test + public void removeIfDropsOnlyMatchingKeys() + { + AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock); + cache.put("introspection::tokenA|scope1", "a1", 500); + cache.put("introspection::tokenA|scope2", "a2", 500); + cache.put("introspection::tokenB|scope1", "b1", 500); + + int removed = cache.removeIf(key -> key.startsWith("introspection::tokenA|")); + + assertEquals(2, removed); + assertNull(cache.getFresh("introspection::tokenA|scope1")); + assertNull(cache.getFresh("introspection::tokenA|scope2")); + assertEquals("other tokens untouched", "b1", + cache.getFresh("introspection::tokenB|scope1")); + } +} 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)); + } +} diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiE2ETest.java b/src/test/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiE2ETest.java new file mode 100644 index 0000000..117cf19 --- /dev/null +++ b/src/test/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiE2ETest.java @@ -0,0 +1,317 @@ +/* + * 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.assertSame; +import static org.junit.Assert.fail; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import com.authlete.common.api.AuthleteApi; +import com.authlete.common.api.AuthleteApiException; +import com.authlete.common.dto.IntrospectionRequest; +import com.authlete.common.dto.IntrospectionResponse; +import com.authlete.common.dto.RevocationRequest; + + +/** + * End-to-end tests that exercise the whole resilience layer through the real + * dynamic proxy produced by {@link ResilientAuthleteApiFactory#wrap}, driving a + * programmable fake {@link AuthleteApi} backend so caching, retry, permanent + * error handling, circuit breaking and stale fallback can all be asserted + * without a network or a real Authlete server. + */ +public class ResilientAuthleteApiE2ETest +{ + /** Every resilience knob this test touches, reset between cases. */ + private static final String[] KEYS = { + "resilience.enabled", + "resilience.retry.enabled", + "resilience.retry.maxAttempts", + "resilience.retry.baseDelayMillis", + "resilience.retry.jitterMillis", + "resilience.retry.maxTotalMillis", + "resilience.breaker.enabled", + "resilience.breaker.failureThreshold", + "resilience.breaker.windowSeconds", + "resilience.breaker.openSeconds", + "resilience.cache.enabled", + "resilience.cache.ttl.introspection", + "resilience.cache.staleSeconds", + }; + + + /** + * Programmable {@link AuthleteApi} backend. Only {@code introspection} is + * meaningful; every other method returns {@code null}. The next queued + * status (or {@link #always}, when set) decides whether a call throws an + * {@link AuthleteApiException} or returns {@link #response}. + */ + private static final class Backend implements InvocationHandler + { + final AtomicInteger calls = new AtomicInteger(); + final ConcurrentLinkedQueue statuses = new ConcurrentLinkedQueue<>(); + volatile Integer always = null; + final IntrospectionResponse response; + + Backend(IntrospectionResponse response) + { + this.response = response; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + { + if (method.getDeclaringClass() == Object.class) + { + switch (method.getName()) + { + case "equals": return proxy == args[0]; + case "hashCode": return System.identityHashCode(proxy); + default: return "Backend"; + } + } + + if (!"introspection".equals(method.getName())) + { + return null; + } + + calls.incrementAndGet(); + + Integer status = (always != null) ? always : statuses.poll(); + + if (status != null && status.intValue() != 0) + { + throw new AuthleteApiException( + "simulated " + status, status.intValue(), "error", null); + } + + return response; + } + } + + + private final IntrospectionResponse canned = new IntrospectionResponse(); + private Backend backend; + private AuthleteApi api; + + + @Before + public void setUp() + { + // A baseline that is friendly to the cache/retry cases; individual + // tests override the few knobs they care about before building the API. + set("resilience.enabled", "true"); + set("resilience.retry.enabled", "true"); + set("resilience.retry.maxAttempts", "4"); + set("resilience.retry.baseDelayMillis", "5"); + set("resilience.retry.jitterMillis", "0"); + set("resilience.retry.maxTotalMillis", "60000"); + set("resilience.breaker.enabled", "true"); + set("resilience.breaker.failureThreshold", "100"); + set("resilience.breaker.windowSeconds", "30"); + set("resilience.breaker.openSeconds", "60"); + set("resilience.cache.enabled", "true"); + set("resilience.cache.ttl.introspection", "30"); + set("resilience.cache.staleSeconds", "1800"); + + backend = new Backend(canned); + } + + + @After + public void tearDown() + { + for (String key : KEYS) + { + System.clearProperty(key); + } + } + + + /** Build the resilient proxy from the current system-property snapshot. */ + private AuthleteApi buildApi() + { + AuthleteApi delegate = (AuthleteApi) Proxy.newProxyInstance( + AuthleteApi.class.getClassLoader(), + new Class[] { AuthleteApi.class }, + backend); + + return ResilientAuthleteApiFactory.wrap(delegate); + } + + + private static IntrospectionRequest request() + { + return new IntrospectionRequest().setToken("token-123"); + } + + + @Test + public void freshHitIsServedFromCacheWithoutCallingBackend() throws Exception + { + api = buildApi(); + + IntrospectionResponse first = api.introspection(request()); + IntrospectionResponse second = api.introspection(request()); + + assertSame("same cached instance returned", first, second); + assertSame(canned, first); + assertEquals("backend invoked only once", 1, backend.calls.get()); + } + + + @Test + public void transientFailuresAreRetriedThenSucceed() throws Exception + { + backend.statuses.add(503); + backend.statuses.add(503); + + api = buildApi(); + + IntrospectionResponse result = api.introspection(request()); + + assertSame("succeeds after the backend recovers", canned, result); + assertEquals("two failures + one success", 3, backend.calls.get()); + } + + + @Test + public void permanentErrorIsNotRetried() throws Exception + { + backend.statuses.add(400); + + api = buildApi(); + + try + { + api.introspection(request()); + fail("expected the 400 to propagate"); + } + catch (AuthleteApiException e) + { + assertEquals(400, e.getStatusCode()); + } + + assertEquals("no retry on a permanent error", 1, backend.calls.get()); + } + + + @Test + public void breakerOpensAndFailsFastWithoutCallingBackend() throws Exception + { + set("resilience.retry.enabled", "false"); + set("resilience.breaker.failureThreshold", "3"); + backend.always = 503; + + api = buildApi(); + + for (int i = 0; i < 6; i++) + { + try + { + api.introspection(request()); + fail("every call should fail while the backend is down"); + } + catch (AuthleteApiException expected) + { + // expected + } + } + + // Only the first three calls reach the backend; after the threshold the + // breaker is open and subsequent calls fail fast. + assertEquals("backend shielded once the breaker opens", 3, backend.calls.get()); + } + + + @Test + public void revocationEvictsCachedIntrospectionForTheToken() throws Exception + { + api = buildApi(); + + // Prime the cache; the second call is served from it. + assertSame(canned, api.introspection(request())); + api.introspection(request()); + assertEquals("backend hit once while cached", 1, backend.calls.get()); + + // Revoke the same token through the proxy (URL-encoded form parameters). + api.revocation(new RevocationRequest() + .setParameters("token=token-123&token_type_hint=access_token")); + + // The cached entry must be gone: the next introspection reaches the + // backend again instead of reporting the revoked token as active. + api.introspection(request()); + assertEquals("cache evicted on revocation", 2, backend.calls.get()); + } + + + @Test + public void revocationOfAnotherTokenKeepsUnrelatedCacheEntries() throws Exception + { + api = buildApi(); + + assertSame(canned, api.introspection(request())); + assertEquals(1, backend.calls.get()); + + // Revoking a different token must not evict this token's entry. + api.revocation(new RevocationRequest() + .setParameters("token=other-token&token_type_hint=access_token")); + + api.introspection(request()); + assertEquals("unrelated entry still served from cache", 1, backend.calls.get()); + } + + + @Test + public void staleEntryIsServedAsFallbackOnFailure() throws Exception + { + set("resilience.retry.enabled", "false"); + set("resilience.cache.ttl.introspection", "1"); // 1s fresh, then stale + + api = buildApi(); + + // Prime the cache with a successful response. + assertSame(canned, api.introspection(request())); + assertEquals(1, backend.calls.get()); + + // Let the fresh TTL lapse so the entry is only "stale". + Thread.sleep(1200); + + // Backend now fails; the layer should serve the stale value instead. + backend.always = 503; + IntrospectionResponse result = api.introspection(request()); + + assertSame("stale cached value served as fallback", canned, result); + assertEquals("backend was attempted once more before falling back", + 2, backend.calls.get()); + } + + + private static void set(String key, String value) + { + System.setProperty(key, value); + } +}