diff --git a/pom.xml b/pom.xml
index f8bbb54..affeba5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -157,6 +157,14 @@
gson
2.10.1
+
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java
index eb41d10..a8e51fa 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationDecisionEndpoint.java
@@ -29,7 +29,7 @@
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.Client;
import com.authlete.common.types.User;
import com.authlete.jaxrs.AuthorizationDecisionHandler.Params;
@@ -113,7 +113,7 @@ public Response post(
session.getId());
// Handle the end-user's decision.
- return handle(AuthleteApiFactory.getDefaultApi(), spi, params);
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(), spi, params);
}
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java
index d21fc0b..efd3e6b 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/AuthorizationEndpoint.java
@@ -27,7 +27,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BaseAuthorizationEndpoint;
@@ -104,7 +104,7 @@ public Response post(
*/
private Response handle(HttpServletRequest request, MultivaluedMap parameters)
{
- return handle(AuthleteApiFactory.getDefaultApi(),
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(),
new AuthorizationRequestHandlerSpiImpl(request), parameters);
}
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java
index 58631f5..39f7cdd 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/ClientRegistrationEndpoint.java
@@ -34,7 +34,7 @@
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.util.Utils;
import com.authlete.jaxrs.BaseClientRegistrationEndpoint;
import com.authlete.jaxrs.server.obb.util.ObbUtils;
@@ -71,7 +71,7 @@ public Response register(
@Context HttpServletRequest httpServletRequest)
{
// The interface of Authlete APIs.
- AuthleteApi api = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi();
// Pre-process the request body as necessary.
json = preprocessRequestBody(httpServletRequest, json);
@@ -92,7 +92,7 @@ public Response read(
@Context HttpServletRequest httpServletRequest)
{
// The interface of Authlete APIs.
- AuthleteApi api = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi();
// Extra process before executing the "read" operation.
preprocessClient(httpServletRequest, api, clientId);
@@ -115,7 +115,7 @@ public Response update(
@Context HttpServletRequest httpServletRequest)
{
// The interface of Authlete APIs.
- AuthleteApi api = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi();
// Pre-process the request body as necessary.
json = preprocessRequestBody(httpServletRequest, json);
@@ -136,7 +136,7 @@ public Response delete(
@Context HttpServletRequest httpServletRequest)
{
// The interface of Authlete APIs.
- AuthleteApi api = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi();
// Extra process before executing the "delete" operation.
preprocessClient(httpServletRequest, api, clientId);
diff --git a/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java
index e0d1523..25034bd 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/ConfigurationEndpoint.java
@@ -22,7 +22,7 @@
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.ServiceConfigurationRequest;
import com.authlete.jaxrs.BaseConfigurationEndpoint;
@@ -105,7 +105,7 @@ public Response get(
)
{
// An AuthleteApi instance to access Authlete APIs.
- AuthleteApi api = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi();
// If either or both of the 'pretty' request parameter
// and the 'patch' request parameter are given.
diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java
index b2d63bf..5e880f5 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/FederationConfigurationEndpoint.java
@@ -20,7 +20,7 @@
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.FederationConfigurationRequest;
import com.authlete.common.types.EntityType;
import com.authlete.jaxrs.BaseFederationConfigurationEndpoint;
@@ -78,6 +78,6 @@ public class FederationConfigurationEndpoint extends BaseFederationConfiguration
public Response get()
{
// Handle the request to the endpoint.
- return handle(AuthleteApiFactory.getDefaultApi(), REQUEST);
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(), REQUEST);
}
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java
index 51c722e..d0468c4 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/FederationRegistrationEndpoint.java
@@ -21,7 +21,7 @@
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.FederationRegistrationRequest;
import com.authlete.jaxrs.BaseFederationRegistrationEndpoint;
@@ -89,7 +89,7 @@ public Response entityConfiguration(String jwt)
{
// Client registration by a relying party's entity configuration.
return handle(
- AuthleteApiFactory.getDefaultApi(),
+ ResilientAuthleteApiFactory.getDefaultApi(),
request().setEntityConfiguration(jwt));
}
@@ -100,7 +100,7 @@ public Response trustChain(String json)
{
// Client registration by a relying party's trust chain.
return handle(
- AuthleteApiFactory.getDefaultApi(),
+ ResilientAuthleteApiFactory.getDefaultApi(),
request().setTrustChain(json));
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java
index f379641..3d26d1c 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/GrantManagementEndpoint.java
@@ -24,7 +24,7 @@
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BaseGrantManagementEndpoint;
@@ -47,7 +47,7 @@ public Response query(
@PathParam("grantId") String grantId)
{
// Handle the grant management 'query' request.
- return handle(AuthleteApiFactory.getDefaultApi(), req, grantId);
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(), req, grantId);
}
@@ -61,6 +61,6 @@ public Response revoke(
@PathParam("grantId") String grantId)
{
// Handle the grant management 'revoke' request.
- return handle(AuthleteApiFactory.getDefaultApi(), req, grantId);
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(), req, grantId);
}
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java
index 8294935..8988a6c 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/IntrospectionEndpoint.java
@@ -26,7 +26,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.web.BasicCredentials;
import com.authlete.jaxrs.BaseIntrospectionEndpoint;
import com.authlete.jaxrs.IntrospectionRequestHandler.Params;
@@ -104,7 +104,7 @@ public Response post(
Params params = buildParams(parameters, accept, rsEntity);
// Handle the introspection request.
- return handle(AuthleteApiFactory.getDefaultApi(), params);
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(), params);
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java
index 8714fb9..a9e7e4d 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/JwksEndpoint.java
@@ -20,7 +20,7 @@
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BaseJwksEndpoint;
@@ -60,6 +60,6 @@ public class JwksEndpoint extends BaseJwksEndpoint
public Response get()
{
// Handle the JWK Set request.
- return handle(AuthleteApiFactory.getDefaultApi());
+ return handle(ResilientAuthleteApiFactory.getDefaultApi());
}
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java
index 00dca3c..5e3cc4f 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/PushedAuthReqEndpoint.java
@@ -11,7 +11,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BasePushedAuthReqEndpoint;
import com.authlete.jaxrs.PushedAuthReqHandler.Params;
@@ -40,7 +40,7 @@ public Response post(
MultivaluedMap parameters)
{
// Authlete API
- AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi();
// Parameters for Authlete's pushed_auth_req API.
Params params = buildParams(request, parameters);
diff --git a/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java
index 7455c8b..66514fc 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/RevocationEndpoint.java
@@ -27,7 +27,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BaseRevocationEndpoint;
import com.authlete.jaxrs.RevocationRequestHandler.Params;
@@ -57,7 +57,7 @@ public Response post(
MultivaluedMap parameters)
{
// Authlete API
- AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi();
// Parameters for Authlete's /auth/revocation API
Params params = buildParams(request, parameters);
diff --git a/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java
index 034f1c6..0d377b6 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java
@@ -28,7 +28,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.util.Utils;
import com.authlete.jaxrs.BaseTokenEndpoint;
import com.authlete.jaxrs.TokenRequestHandler.Params;
@@ -80,7 +80,7 @@ public Response post(
MultivaluedMap parameters)
{
// Authlete API
- AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi();
// Process the token request in a standard way.
Response response = processTokenRequest(authleteApi, request, parameters);
diff --git a/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java
index 02af84e..42f79e3 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/UserInfoEndpoint.java
@@ -26,7 +26,7 @@
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BaseUserInfoEndpoint;
import com.authlete.jaxrs.UserInfoRequestHandler.Params;
import com.authlete.jaxrs.util.JaxRsUtils;
@@ -124,7 +124,7 @@ private Response handle(
{
Params params = buildParams(request, body, accessToken, dpop);
- return handle(AuthleteApiFactory.getDefaultApi(),
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(),
new UserInfoRequestHandlerSpiImpl(), params);
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java
index 2198cd5..4b1737e 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/attestation/AttestationChallengeEndpoint.java
@@ -21,7 +21,7 @@
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.AttestationChallengeRequest;
import com.authlete.jaxrs.BaseAttestationChallengeEndpoint;
@@ -60,7 +60,7 @@ public class AttestationChallengeEndpoint extends BaseAttestationChallengeEndpoi
public Response post()
{
// Authlete API interface
- AuthleteApi api = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi api = ResilientAuthleteApiFactory.getDefaultApi();
// Request to the Authlete's /api/{service-id}/attestation/challenge API
AttestationChallengeRequest request =
diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java
index 4a3a1cb..deb3eac 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationCallbackEndpoint.java
@@ -27,7 +27,7 @@
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result;
import com.authlete.common.types.User;
import com.authlete.jaxrs.BackchannelAuthenticationCompleteRequestHandler;
@@ -106,7 +106,7 @@ private Response doProcess(AsyncAuthenticationCallbackRequest request)
// Complete the authentication and authorization process.
new BackchannelAuthenticationCompleteRequestHandler(
- AuthleteApiFactory.getDefaultApi(),
+ ResilientAuthleteApiFactory.getDefaultApi(),
new BackchannelAuthenticationCompleteHandlerSpiImpl(
result, user, authTime, acrs, errorDescription, null)
)
diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java
index bdeb40d..434d45f 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BackchannelAuthenticationEndpoint.java
@@ -27,7 +27,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BackchannelAuthenticationRequestHandler.Params;
import com.authlete.jaxrs.BaseBackchannelAuthenticationEndpoint;
@@ -51,7 +51,7 @@ public Response post(
MultivaluedMap parameters)
{
// Authlete API
- AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi();
// Parameters for Authlete's /backchannel/authentication API
Params params = buildParams(request, parameters);
diff --git a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java
index e86032c..af58baa 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/backchannel/BaseAuthenticationDeviceProcessor.java
@@ -21,7 +21,7 @@
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.dto.Scope;
import com.authlete.common.dto.BackchannelAuthenticationCompleteRequest.Result;
import com.authlete.common.types.User;
@@ -296,7 +296,7 @@ protected void completeWithTransactionFailed()
protected void complete(Result result, Date authTime, String errorDescription, URI errorUri)
{
new BackchannelAuthenticationCompleteRequestHandler(
- AuthleteApiFactory.getDefaultApi(),
+ ResilientAuthleteApiFactory.getDefaultApi(),
new BackchannelAuthenticationCompleteHandlerSpiImpl(
result, mUser, authTime, mAcrs, errorDescription, errorUri)
)
diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java
index caf0824..ae07cb2 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceAuthorizationEndpoint.java
@@ -27,7 +27,7 @@
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import com.authlete.common.api.AuthleteApi;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.jaxrs.BaseDeviceAuthorizationEndpoint;
import com.authlete.jaxrs.DeviceAuthorizationRequestHandler.Params;
@@ -54,7 +54,7 @@ public Response post(
MultivaluedMap parameters)
{
// Authlete API
- AuthleteApi authleteApi = AuthleteApiFactory.getDefaultApi();
+ AuthleteApi authleteApi = ResilientAuthleteApiFactory.getDefaultApi();
// Parameters for Authlete's /device/authorization API
Params params = buildParams(request, parameters);
diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java
index 2e320dd..8f74f60 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceCompleteEndpoint.java
@@ -28,7 +28,7 @@
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.types.User;
import com.authlete.jaxrs.BaseDeviceCompleteEndpoint;
@@ -125,7 +125,7 @@ private Response handle(
MultivaluedMap parameters, User user, Date userAuthenticatedAt,
String[] acrs, String userCode, String[] claimNames)
{
- return handle(AuthleteApiFactory.getDefaultApi(), new DeviceCompleteRequestHandlerSpiImpl(
+ return handle(ResilientAuthleteApiFactory.getDefaultApi(), new DeviceCompleteRequestHandlerSpiImpl(
parameters, user, userAuthenticatedAt, acrs), userCode, claimNames);
}
}
diff --git a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java
index 4a66835..92950b8 100644
--- a/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java
+++ b/src/main/java/com/authlete/jaxrs/server/api/device/DeviceVerificationEndpoint.java
@@ -32,7 +32,7 @@
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import org.glassfish.jersey.server.mvc.Viewable;
-import com.authlete.common.api.AuthleteApiFactory;
+import com.authlete.jaxrs.server.resilience.ResilientAuthleteApiFactory;
import com.authlete.common.types.User;
import com.authlete.jaxrs.BaseDeviceVerificationEndpoint;
import com.authlete.jaxrs.DeviceVerificationPageModel;
@@ -174,7 +174,7 @@ private void authenticateUser(HttpSession session, MultivaluedMap
+ * Jitter spreads retries from many clients across time so they do not all
+ * fire simultaneously and create a renewed traffic spike. When the server
+ * tells us exactly how long to wait (via a {@code RateLimit-Reset} header on a
+ * {@code 429}), that value is honoured instead of the computed delay.
+ *
+ */
+class AuthleteBackoff
+{
+ private final long baseDelayMillis;
+ private final long maxDelayMillis;
+ private final long jitterMillis;
+ private final Random random;
+
+
+ AuthleteBackoff(long baseDelayMillis, long maxDelayMillis, long jitterMillis)
+ {
+ this(baseDelayMillis, maxDelayMillis, jitterMillis, new Random());
+ }
+
+
+ /**
+ * Package-private constructor that allows an injected {@link Random} for
+ * deterministic testing.
+ */
+ AuthleteBackoff(long baseDelayMillis, long maxDelayMillis, long jitterMillis, Random random)
+ {
+ this.baseDelayMillis = baseDelayMillis;
+ this.maxDelayMillis = maxDelayMillis;
+ this.jitterMillis = jitterMillis;
+ this.random = random;
+ }
+
+
+ /**
+ * Compute the delay (milliseconds) before the given retry attempt.
+ *
+ * @param attempt
+ * The 1-based retry number (1 = first retry, 2 = second retry, ...).
+ *
+ * @param explicitDelayMillis
+ * An explicit delay requested by the server (e.g. from a
+ * {@code RateLimit-Reset} header), or {@code null} if none. When
+ * present, it forms the base delay instead of the exponential value.
+ *
+ * @return
+ * The delay to sleep, clamped to {@code [0, maxDelayMillis]}.
+ */
+ long delayMillis(int attempt, Long explicitDelayMillis)
+ {
+ long base;
+
+ if (explicitDelayMillis != null)
+ {
+ // Honour the server's instruction.
+ base = explicitDelayMillis;
+ }
+ else
+ {
+ // baseDelay * 2^(attempt-1), guarding against overflow.
+ int shift = Math.max(0, attempt - 1);
+
+ if (shift >= 62)
+ {
+ base = maxDelayMillis;
+ }
+ else
+ {
+ base = baseDelayMillis << shift;
+ }
+ }
+
+ long jitter = (jitterMillis > 0) ? (long) (random.nextDouble() * jitterMillis) : 0L;
+
+ long delay = base + jitter;
+
+ if (delay < 0 || delay > maxDelayMillis)
+ {
+ delay = maxDelayMillis;
+ }
+
+ return delay;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java
new file mode 100644
index 0000000..f432008
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethods.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.lang.reflect.Method;
+import com.authlete.common.dto.CredentialIssuerJwksRequest;
+import com.authlete.common.dto.CredentialIssuerMetadataRequest;
+import com.authlete.common.dto.IntrospectionRequest;
+import com.authlete.common.dto.ServiceConfigurationRequest;
+import com.authlete.common.dto.StandardIntrospectionRequest;
+
+
+/**
+ * Decides which {@link com.authlete.common.api.AuthleteApi AuthleteApi} methods
+ * are cacheable, and builds a stable cache key and TTL for each call.
+ *
+ *
+ * Only the idempotent read endpoints named in Authlete's "Rate Limit Best
+ * Practices" guide are cached:
+ *
+ *
+ * - {@code getServiceConfiguration} — service discovery document
+ * - {@code getServiceJwks} — service JWK Set
+ * - {@code getClient} — client metadata
+ * - {@code credentialIssuerMetadata} — OID4VCI issuer metadata
+ * - {@code credentialIssuerJwks} — OID4VCI issuer JWK Set
+ * - {@code introspection} / {@code standardIntrospection} — token
+ * introspection (short TTL; see {@link CachePolicy#capByTokenExpiry})
+ *
+ *
+ *
+ * Any other method returns {@code null} from {@link #policyFor(Method, Object[])}
+ * and is therefore never cached.
+ *
+ */
+class AuthleteCacheableMethods
+{
+ /**
+ * The decision for a single cacheable call: the namespaced cache key and the
+ * TTL to apply.
+ */
+ static final class CachePolicy
+ {
+ final String key;
+ final long ttlMillis;
+
+ /**
+ * When true, the effective TTL must additionally be capped so the entry
+ * never outlives the token's own expiry (introspection only).
+ */
+ final boolean capByTokenExpiry;
+
+ CachePolicy(String key, long ttlMillis, boolean capByTokenExpiry)
+ {
+ this.key = key;
+ this.ttlMillis = ttlMillis;
+ this.capByTokenExpiry = capByTokenExpiry;
+ }
+ }
+
+
+ private final ResilienceConfig config;
+
+
+ AuthleteCacheableMethods(ResilienceConfig config)
+ {
+ this.config = config;
+ }
+
+
+ /**
+ * Return the caching policy for the given method invocation, or {@code null}
+ * if the method must not be cached.
+ */
+ CachePolicy policyFor(Method method, Object[] args)
+ {
+ String name = method.getName();
+ int argc = (args == null) ? 0 : args.length;
+
+ switch (name)
+ {
+ case "getServiceConfiguration":
+ return serviceConfiguration(args, argc);
+
+ case "getServiceJwks":
+ return key("getServiceJwks", joinArgs(args),
+ config.getCacheTtlServiceJwks(), false);
+
+ case "getClient":
+ // getClient(long) and getClient(String); both identify one client.
+ return key("getClient", String.valueOf(args[0]),
+ config.getCacheTtlClient(), false);
+
+ case "credentialIssuerMetadata":
+ if (args[0] instanceof CredentialIssuerMetadataRequest)
+ {
+ CredentialIssuerMetadataRequest req = (CredentialIssuerMetadataRequest) args[0];
+ return key("credentialIssuerMetadata", String.valueOf(req.isPretty()),
+ config.getCacheTtlCredentialIssuerMetadata(), false);
+ }
+ return null;
+
+ case "credentialIssuerJwks":
+ if (args[0] instanceof CredentialIssuerJwksRequest)
+ {
+ CredentialIssuerJwksRequest req = (CredentialIssuerJwksRequest) args[0];
+ return key("credentialIssuerJwks", String.valueOf(req.isPretty()),
+ config.getCacheTtlCredentialIssuerJwks(), false);
+ }
+ return null;
+
+ case "introspection":
+ if (args[0] instanceof IntrospectionRequest)
+ {
+ return introspection((IntrospectionRequest) args[0]);
+ }
+ return null;
+
+ case "standardIntrospection":
+ if (args[0] instanceof StandardIntrospectionRequest)
+ {
+ return standardIntrospection((StandardIntrospectionRequest) args[0]);
+ }
+ return null;
+
+ default:
+ return null;
+ }
+ }
+
+
+ private CachePolicy serviceConfiguration(Object[] args, int argc)
+ {
+ long ttl = config.getCacheTtlServiceConfiguration();
+
+ if (argc == 1 && args[0] instanceof ServiceConfigurationRequest)
+ {
+ ServiceConfigurationRequest req = (ServiceConfigurationRequest) args[0];
+ String detail = req.isPretty() + "|" + req.getPatch();
+ return key("getServiceConfiguration", detail, ttl, false);
+ }
+
+ // getServiceConfiguration() or getServiceConfiguration(boolean).
+ return key("getServiceConfiguration", joinArgs(args), ttl, false);
+ }
+
+
+ private CachePolicy introspection(IntrospectionRequest req)
+ {
+ // A DPoP proof is unique per request (jti/iat), and HTTP message
+ // signature inputs vary per request too. A cached result could never
+ // be legitimately reused for them, and reusing one would skip the
+ // per-request proof validation, so such calls are never cached.
+ if (req.getDpop() != null
+ || req.getMessage() != null
+ || req.getHeaders() != null
+ || req.getRequiredComponents() != null)
+ {
+ return null;
+ }
+
+ // The key must capture every remaining input that influences the
+ // introspection result, so that requests differing in any binding
+ // (e.g. mTLS client certificate or target URI) never share an entry.
+ StringBuilder detail = new StringBuilder();
+ detail.append(req.getToken());
+ detail.append('|').append(join(req.getScopes()));
+ detail.append('|').append(req.getSubject());
+ detail.append('|').append(req.getClientCertificate());
+ detail.append('|').append(req.getHtm());
+ detail.append('|').append(req.getHtu());
+ detail.append('|').append(join(req.getResources()));
+ detail.append('|').append(req.getUri());
+ detail.append('|').append(req.getTargetUri());
+ detail.append('|').append(join(req.getAcrValues()));
+ detail.append('|').append(req.getMaxAge());
+ detail.append('|').append(req.isRequestBodyContained());
+ detail.append('|').append(req.isDpopNonceRequired());
+
+ return key("introspection", detail.toString(),
+ config.getCacheTtlIntrospection(), true);
+ }
+
+
+ private CachePolicy standardIntrospection(StandardIntrospectionRequest req)
+ {
+ // Besides the token parameters, the response depends on the resource
+ // server's identity and the requested response format/protection, so
+ // all of them participate in the key. Otherwise one resource server
+ // could receive a response cached for another.
+ StringBuilder detail = new StringBuilder();
+ detail.append(req.getParameters());
+ detail.append('|').append(req.isWithHiddenProperties());
+ detail.append('|').append(req.getRsUri());
+ detail.append('|').append(req.getHttpAcceptHeader());
+ detail.append('|').append(req.getIntrospectionSignAlg());
+ detail.append('|').append(req.getIntrospectionEncryptionAlg());
+ detail.append('|').append(req.getIntrospectionEncryptionEnc());
+ detail.append('|').append(req.getSharedKeyForSign());
+ detail.append('|').append(req.getSharedKeyForEncryption());
+ detail.append('|').append(req.getPublicKeyForEncryption());
+
+ return key("standardIntrospection", detail.toString(),
+ config.getCacheTtlStandardIntrospection(), true);
+ }
+
+
+ private static CachePolicy key(String namespace, String detail, long ttlMillis, boolean capByTokenExpiry)
+ {
+ // Token (and similar) values can be long; namespacing keeps lookups O(1)
+ // in the shared map without risk of cross-method collisions.
+ return new CachePolicy(namespace + "::" + detail, ttlMillis, capByTokenExpiry);
+ }
+
+
+ private static String joinArgs(Object[] args)
+ {
+ if (args == null || args.length == 0)
+ {
+ return "";
+ }
+
+ StringBuilder sb = new StringBuilder();
+
+ for (int i = 0; i < args.length; i++)
+ {
+ if (i > 0)
+ {
+ sb.append('|');
+ }
+ sb.append(String.valueOf(args[i]));
+ }
+
+ return sb.toString();
+ }
+
+
+ private static String join(Object[] values)
+ {
+ if (values == null || values.length == 0)
+ {
+ return "";
+ }
+
+ StringBuilder sb = new StringBuilder();
+
+ for (int i = 0; i < values.length; i++)
+ {
+ if (i > 0)
+ {
+ sb.append(',');
+ }
+ sb.append(values[i]);
+ }
+
+ return sb.toString();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java
new file mode 100644
index 0000000..fcde803
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreaker.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.util.function.LongSupplier;
+
+
+/**
+ * A classic three-state circuit breaker (Closed / Open / Half-Open) guarding a
+ * single Authlete API method.
+ *
+ *
+ * - Closed — normal operation; transient failures are counted
+ * within a rolling window. Reaching the failure threshold trips the
+ * breaker Open.
+ * - Open — calls fail fast without touching the backend. After
+ * the open timeout elapses, the breaker moves to Half-Open.
+ * - Half-Open — a limited number of trial calls are allowed
+ * through. A success closes the breaker; a failure reopens it.
+ *
+ *
+ *
+ * One breaker is kept per API method (see {@link AuthleteCircuitBreakerRegistry}) so
+ * that failures in one endpoint (e.g. client management) do not block an
+ * unrelated, higher-priority endpoint (e.g. introspection).
+ *
+ *
+ *
+ * All state transitions are guarded by intrinsic locking; the breaker is safe
+ * for concurrent use.
+ *
+ */
+class AuthleteCircuitBreaker
+{
+ enum State
+ {
+ CLOSED,
+ OPEN,
+ HALF_OPEN
+ }
+
+
+ private final int failureThreshold;
+ private final long windowMillis;
+ private final long openMillis;
+ private final int halfOpenTrials;
+ private final LongSupplier clock;
+
+ private State state = State.CLOSED;
+ private int failureCount = 0;
+ private boolean windowOpen = false;
+ private long windowStart = 0L;
+ private long openedAt = 0L;
+ private int halfOpenInFlight = 0;
+
+
+ AuthleteCircuitBreaker(int failureThreshold, long windowMillis, long openMillis, int halfOpenTrials)
+ {
+ this(failureThreshold, windowMillis, openMillis, halfOpenTrials, System::currentTimeMillis);
+ }
+
+
+ /**
+ * Package-private constructor that allows an injected clock for testing.
+ */
+ AuthleteCircuitBreaker(int failureThreshold, long windowMillis, long openMillis,
+ int halfOpenTrials, LongSupplier clock)
+ {
+ this.failureThreshold = failureThreshold;
+ this.windowMillis = windowMillis;
+ this.openMillis = openMillis;
+ this.halfOpenTrials = Math.max(1, halfOpenTrials);
+ this.clock = clock;
+ }
+
+
+ /**
+ * Decide whether a request may proceed right now. When this returns
+ * {@code true} in the half-open state, a trial slot is reserved and must be
+ * released via {@link #recordSuccess()} or {@link #recordFailure()}.
+ */
+ synchronized boolean allowRequest()
+ {
+ long now = clock.getAsLong();
+
+ switch (state)
+ {
+ case CLOSED:
+ return true;
+
+ case OPEN:
+ if (now - openedAt >= openMillis)
+ {
+ // Time to probe whether the backend has recovered.
+ state = State.HALF_OPEN;
+ halfOpenInFlight = 1;
+ return true;
+ }
+ return false;
+
+ case HALF_OPEN:
+ default:
+ if (halfOpenInFlight < halfOpenTrials)
+ {
+ halfOpenInFlight++;
+ return true;
+ }
+ return false;
+ }
+ }
+
+
+ /**
+ * Record a successful call.
+ */
+ synchronized void recordSuccess()
+ {
+ // Any success (in either Closed or Half-Open) restores normal operation.
+ reset();
+ }
+
+
+ /**
+ * Release a half-open trial slot without judging backend health. Used when
+ * a call granted by {@link #allowRequest()} ends in a way that says nothing
+ * about whether the backend has recovered (e.g. an unexpected local
+ * error), so the slot becomes available for the next probe.
+ */
+ synchronized void releaseTrial()
+ {
+ if (state == State.HALF_OPEN && halfOpenInFlight > 0)
+ {
+ halfOpenInFlight--;
+ }
+ }
+
+
+ /**
+ * Record a (transient) failure.
+ */
+ synchronized void recordFailure()
+ {
+ long now = clock.getAsLong();
+
+ if (state == State.HALF_OPEN)
+ {
+ // The probe failed: reopen immediately.
+ trip(now);
+ return;
+ }
+
+ // CLOSED: count failures within the rolling window.
+ if (!windowOpen || now - windowStart > windowMillis)
+ {
+ // Start a fresh window.
+ windowOpen = true;
+ windowStart = now;
+ failureCount = 0;
+ }
+
+ failureCount++;
+
+ if (failureCount >= failureThreshold)
+ {
+ trip(now);
+ }
+ }
+
+
+ private void trip(long now)
+ {
+ state = State.OPEN;
+ openedAt = now;
+ failureCount = 0;
+ windowOpen = false;
+ halfOpenInFlight = 0;
+ }
+
+
+ private void reset()
+ {
+ state = State.CLOSED;
+ failureCount = 0;
+ windowOpen = false;
+ halfOpenInFlight = 0;
+ }
+
+
+ synchronized State getState()
+ {
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java
new file mode 100644
index 0000000..1963471
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerRegistry.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.util.concurrent.ConcurrentHashMap;
+
+
+/**
+ * Lazily creates and holds one {@link AuthleteCircuitBreaker} per Authlete API method.
+ *
+ *
+ * Keeping breakers per method isolates failures: a breaker that has tripped for
+ * one endpoint (e.g. client management) does not affect another, higher-priority
+ * endpoint (e.g. introspection), as recommended by the "Isolate Resources"
+ * advice in Authlete's "Rate Limit Best Practices" guide.
+ *
+ */
+class AuthleteCircuitBreakerRegistry
+{
+ private final ConcurrentHashMap breakers =
+ new ConcurrentHashMap();
+
+ private final int failureThreshold;
+ private final long windowMillis;
+ private final long openMillis;
+ private final int halfOpenTrials;
+
+
+ AuthleteCircuitBreakerRegistry(ResilienceConfig config)
+ {
+ this.failureThreshold = config.getBreakerFailureThreshold();
+ this.windowMillis = config.getBreakerWindowMillis();
+ this.openMillis = config.getBreakerOpenMillis();
+ this.halfOpenTrials = config.getBreakerHalfOpenTrials();
+ }
+
+
+ /**
+ * Get the breaker for the given method name, creating it on first use.
+ */
+ AuthleteCircuitBreaker forMethod(String methodName)
+ {
+ AuthleteCircuitBreaker existing = breakers.get(methodName);
+
+ if (existing != null)
+ {
+ return existing;
+ }
+
+ AuthleteCircuitBreaker created =
+ new AuthleteCircuitBreaker(failureThreshold, windowMillis, openMillis, halfOpenTrials);
+
+ AuthleteCircuitBreaker previous = breakers.putIfAbsent(methodName, created);
+
+ return (previous != null) ? previous : created;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java
new file mode 100644
index 0000000..cd64007
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCache.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
+
+
+/**
+ * A small, thread-safe, in-memory TTL cache for responses of idempotent
+ * Authlete API calls.
+ *
+ *
+ * Each entry has two lifetimes:
+ *
+ *
+ * - fresh — until its TTL elapses; {@link #getFresh(String)}
+ * returns it and the value is served directly without calling Authlete.
+ * - stale — for an additional {@code staleMillis} after the TTL;
+ * {@link #getStale(String)} returns it. Stale values are used only as a
+ * fast-fail fallback while the circuit breaker is open, so that a degraded
+ * but functional response can be served during an outage.
+ *
+ *
+ *
+ * The cache is intentionally dependency-free (a plain {@link ConcurrentHashMap})
+ * so it is easy to read and copy. For cross-instance caching, replace the map
+ * with a shared store such as Redis.
+ *
+ *
+ *
+ * A single instance is shared across all cached methods; cache keys are
+ * namespaced by method (see {@link AuthleteCacheableMethods}) so there is no risk of
+ * collision between, say, a client id and a service-configuration request.
+ *
+ */
+class AuthleteResponseCache
+{
+ private static final class Entry
+ {
+ final Object value;
+ final long freshUntil;
+ final long staleUntil;
+
+ Entry(Object value, long freshUntil, long staleUntil)
+ {
+ this.value = value;
+ this.freshUntil = freshUntil;
+ this.staleUntil = staleUntil;
+ }
+ }
+
+
+ private final ConcurrentHashMap map = new ConcurrentHashMap();
+ private final long staleMillis;
+ private final int maxEntries;
+ private final LongSupplier clock;
+
+
+ AuthleteResponseCache(long staleMillis, int maxEntries)
+ {
+ this(staleMillis, maxEntries, System::currentTimeMillis);
+ }
+
+
+ /**
+ * Package-private constructor that allows an injected clock for testing.
+ */
+ AuthleteResponseCache(long staleMillis, int maxEntries, LongSupplier clock)
+ {
+ this.staleMillis = staleMillis;
+ this.maxEntries = maxEntries;
+ this.clock = clock;
+ }
+
+
+ /**
+ * Return the cached value for the key only if it is still fresh, otherwise
+ * {@code null}.
+ */
+ Object getFresh(String key)
+ {
+ Entry e = map.get(key);
+
+ if (e == null)
+ {
+ return null;
+ }
+
+ long now = clock.getAsLong();
+
+ if (now < e.freshUntil)
+ {
+ return e.value;
+ }
+
+ // Expired beyond the stale window: drop it eagerly.
+ if (now >= e.staleUntil)
+ {
+ map.remove(key, e);
+ }
+
+ return null;
+ }
+
+
+ /**
+ * Return the cached value for the key if it still exists within the stale
+ * window (whether fresh or only stale), otherwise {@code null}.
+ */
+ Object getStale(String key)
+ {
+ Entry e = map.get(key);
+
+ if (e == null)
+ {
+ return null;
+ }
+
+ long now = clock.getAsLong();
+
+ if (now < e.staleUntil)
+ {
+ return e.value;
+ }
+
+ map.remove(key, e);
+
+ return null;
+ }
+
+
+ /**
+ * Store a value under the key with the given TTL (milliseconds). The stale
+ * window is added on top of the TTL.
+ */
+ void put(String key, Object value, long ttlMillis)
+ {
+ if (value == null || ttlMillis <= 0)
+ {
+ return;
+ }
+
+ long now = clock.getAsLong();
+
+ // Bound memory: evict expired entries first, then refuse new keys if
+ // still over the limit (existing keys are always allowed to refresh).
+ if (map.size() >= maxEntries && !map.containsKey(key))
+ {
+ purgeExpired(now);
+
+ if (map.size() >= maxEntries)
+ {
+ return;
+ }
+ }
+
+ map.put(key, new Entry(value, now + ttlMillis, now + ttlMillis + staleMillis));
+ }
+
+
+ private void purgeExpired(long now)
+ {
+ for (Map.Entry e : map.entrySet())
+ {
+ if (now >= e.getValue().staleUntil)
+ {
+ map.remove(e.getKey(), e.getValue());
+ }
+ }
+ }
+
+
+ /**
+ * Remove all entries. Exposed for completeness / testing.
+ */
+ void clear()
+ {
+ map.clear();
+ }
+
+
+ int size()
+ {
+ return map.size();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java
new file mode 100644
index 0000000..e17bb08
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicy.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.util.List;
+import java.util.Map;
+
+
+/**
+ * Decides whether a failed Authlete API call is transient (worth
+ * retrying) or permanent (never retried), per Authlete's "Rate Limit
+ * Best Practices" guide.
+ *
+ *
+ * Transient failures are:
+ *
+ *
+ * - {@code 429 Too Many Requests} — retry only after the delay in the
+ * {@code RateLimit-Reset} response header (see
+ * {@link #rateLimitResetMillis(Map)}).
+ * - {@code 502 Bad Gateway}, {@code 503 Service Unavailable}, and any other
+ * {@code 5xx}.
+ * - Connection-level errors with no HTTP response (status {@code 0}).
+ *
+ *
+ *
+ * Permanent failures are the remaining {@code 4xx} codes (e.g. {@code 400},
+ * {@code 401}, {@code 403}); these can never succeed without changing the
+ * request, so they are surfaced immediately.
+ *
+ */
+class AuthleteRetryPolicy
+{
+ // Common spellings of the rate-limit reset header, matched case-insensitively.
+ private static final String[] RESET_HEADERS = {
+ "RateLimit-Reset", "Ratelimit-Reset", "X-RateLimit-Reset", "Retry-After"
+ };
+
+
+ /**
+ * Tell whether the given HTTP status code denotes a transient failure that
+ * may be retried.
+ *
+ * @param statusCode
+ * The HTTP status code from {@code AuthleteApiException.getStatusCode()}.
+ * A value of {@code 0} means no HTTP response was received (a
+ * connection-level error), which is treated as transient.
+ */
+ boolean isTransient(int statusCode)
+ {
+ // No HTTP response (connection refused, timeout, DNS failure, ...).
+ if (statusCode == 0)
+ {
+ return true;
+ }
+
+ // Too Many Requests.
+ if (statusCode == 429)
+ {
+ return true;
+ }
+
+ // Any server-side error.
+ if (statusCode >= 500 && statusCode < 600)
+ {
+ return true;
+ }
+
+ // Everything else (notably 4xx) is permanent.
+ return false;
+ }
+
+
+ /**
+ * Extract the rate-limit reset delay (milliseconds) from the response
+ * headers, or {@code null} when no usable value is present.
+ *
+ *
+ * The header value is interpreted as a number of seconds to wait before the
+ * next attempt. Non-numeric or non-positive values are ignored.
+ *
+ */
+ Long rateLimitResetMillis(Map> headers)
+ {
+ if (headers == null || headers.isEmpty())
+ {
+ return null;
+ }
+
+ for (String wanted : RESET_HEADERS)
+ {
+ String value = findHeader(headers, wanted);
+
+ if (value == null)
+ {
+ continue;
+ }
+
+ try
+ {
+ long seconds = Long.parseLong(value.trim());
+
+ if (seconds > 0)
+ {
+ return seconds * 1000L;
+ }
+ }
+ catch (NumberFormatException e)
+ {
+ // Try the next candidate header.
+ }
+ }
+
+ return null;
+ }
+
+
+ private static String findHeader(Map> headers, String name)
+ {
+ for (Map.Entry> e : headers.entrySet())
+ {
+ String key = e.getKey();
+
+ if (key != null && key.equalsIgnoreCase(name))
+ {
+ List values = e.getValue();
+
+ if (values != null && !values.isEmpty())
+ {
+ return values.get(0);
+ }
+ }
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java
new file mode 100644
index 0000000..4209e58
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceConfig.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+/**
+ * Typed, defaulted access to the resilience configuration defined in
+ * {@code resilience.properties} (overridable via JVM system properties).
+ *
+ *
+ * Values are read once at class-load time. The defaults below match the
+ * recommendations in Authlete's "Rate Limit Best Practices" guide, so the
+ * resilience layer behaves sensibly even when {@code resilience.properties}
+ * is absent.
+ *
+ */
+public final class ResilienceConfig
+{
+ private static final ResilienceProperties PROPS = new ResilienceProperties();
+
+ // Master switch.
+ private final boolean enabled;
+
+ // Cache.
+ private final boolean cacheEnabled;
+ private final long cacheTtlServiceConfiguration;
+ private final long cacheTtlServiceJwks;
+ private final long cacheTtlClient;
+ private final long cacheTtlCredentialIssuerMetadata;
+ private final long cacheTtlCredentialIssuerJwks;
+ private final long cacheTtlIntrospection;
+ private final long cacheTtlStandardIntrospection;
+ private final long cacheStaleMillis;
+ private final int cacheMaxEntries;
+
+ // Retry / backoff.
+ private final boolean retryEnabled;
+ private final int retryMaxAttempts;
+ private final long retryBaseDelayMillis;
+ private final long retryMaxTotalMillis;
+ private final long retryJitterMillis;
+
+ // Circuit breaker.
+ private final boolean breakerEnabled;
+ private final int breakerFailureThreshold;
+ private final long breakerWindowMillis;
+ private final long breakerOpenMillis;
+ private final int breakerHalfOpenTrials;
+
+
+ /**
+ * Build a configuration snapshot from {@code resilience.properties} and
+ * system properties.
+ */
+ public ResilienceConfig()
+ {
+ enabled = PROPS.getBoolean("resilience.enabled", true);
+
+ cacheEnabled = PROPS.getBoolean("resilience.cache.enabled", true);
+ cacheTtlServiceConfiguration = seconds("resilience.cache.ttl.serviceConfiguration", 600);
+ cacheTtlServiceJwks = seconds("resilience.cache.ttl.serviceJwks", 600);
+ cacheTtlClient = seconds("resilience.cache.ttl.client", 300);
+ cacheTtlCredentialIssuerMetadata = seconds("resilience.cache.ttl.credentialIssuerMetadata", 600);
+ cacheTtlCredentialIssuerJwks = seconds("resilience.cache.ttl.credentialIssuerJwks", 600);
+ cacheTtlIntrospection = seconds("resilience.cache.ttl.introspection", 30);
+ cacheTtlStandardIntrospection = seconds("resilience.cache.ttl.standardIntrospection", 30);
+ cacheStaleMillis = seconds("resilience.cache.staleSeconds", 1800);
+ cacheMaxEntries = PROPS.getInt("resilience.cache.maxEntries", 10000);
+
+ retryEnabled = PROPS.getBoolean("resilience.retry.enabled", true);
+ retryMaxAttempts = PROPS.getInt("resilience.retry.maxAttempts", 4);
+ retryBaseDelayMillis = PROPS.getLong("resilience.retry.baseDelayMillis", 500);
+ retryMaxTotalMillis = PROPS.getLong("resilience.retry.maxTotalMillis", 60000);
+ retryJitterMillis = PROPS.getLong("resilience.retry.jitterMillis", 200);
+
+ breakerEnabled = PROPS.getBoolean("resilience.breaker.enabled", true);
+ breakerFailureThreshold = PROPS.getInt("resilience.breaker.failureThreshold", 5);
+ breakerWindowMillis = seconds("resilience.breaker.windowSeconds", 30);
+ breakerOpenMillis = seconds("resilience.breaker.openSeconds", 60);
+ breakerHalfOpenTrials = PROPS.getInt("resilience.breaker.halfOpenTrials", 1);
+ }
+
+
+ /**
+ * Read a value expressed in seconds and return it in milliseconds.
+ */
+ private static long seconds(String key, long defaultSeconds)
+ {
+ return PROPS.getLong(key, defaultSeconds) * 1000L;
+ }
+
+
+ public boolean isEnabled()
+ {
+ return enabled;
+ }
+
+
+ public boolean isCacheEnabled()
+ {
+ return cacheEnabled;
+ }
+
+
+ public long getCacheTtlServiceConfiguration()
+ {
+ return cacheTtlServiceConfiguration;
+ }
+
+
+ public long getCacheTtlServiceJwks()
+ {
+ return cacheTtlServiceJwks;
+ }
+
+
+ public long getCacheTtlClient()
+ {
+ return cacheTtlClient;
+ }
+
+
+ public long getCacheTtlCredentialIssuerMetadata()
+ {
+ return cacheTtlCredentialIssuerMetadata;
+ }
+
+
+ public long getCacheTtlCredentialIssuerJwks()
+ {
+ return cacheTtlCredentialIssuerJwks;
+ }
+
+
+ public long getCacheTtlIntrospection()
+ {
+ return cacheTtlIntrospection;
+ }
+
+
+ public long getCacheTtlStandardIntrospection()
+ {
+ return cacheTtlStandardIntrospection;
+ }
+
+
+ public long getCacheStaleMillis()
+ {
+ return cacheStaleMillis;
+ }
+
+
+ public int getCacheMaxEntries()
+ {
+ return cacheMaxEntries;
+ }
+
+
+ public boolean isRetryEnabled()
+ {
+ return retryEnabled;
+ }
+
+
+ public int getRetryMaxAttempts()
+ {
+ return retryMaxAttempts;
+ }
+
+
+ public long getRetryBaseDelayMillis()
+ {
+ return retryBaseDelayMillis;
+ }
+
+
+ public long getRetryMaxTotalMillis()
+ {
+ return retryMaxTotalMillis;
+ }
+
+
+ public long getRetryJitterMillis()
+ {
+ return retryJitterMillis;
+ }
+
+
+ public boolean isBreakerEnabled()
+ {
+ return breakerEnabled;
+ }
+
+
+ public int getBreakerFailureThreshold()
+ {
+ return breakerFailureThreshold;
+ }
+
+
+ public long getBreakerWindowMillis()
+ {
+ return breakerWindowMillis;
+ }
+
+
+ public long getBreakerOpenMillis()
+ {
+ return breakerOpenMillis;
+ }
+
+
+ public int getBreakerHalfOpenTrials()
+ {
+ return breakerHalfOpenTrials;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java
new file mode 100644
index 0000000..ef07880
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilienceProperties.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+import com.authlete.jaxrs.server.util.TypedSystemProperties;
+
+
+/**
+ * Reads resilience configuration from {@code resilience.properties} (on the
+ * classpath) with JVM system properties taking precedence.
+ *
+ *
+ * This mirrors {@link com.authlete.jaxrs.server.util.ServerProperties
+ * ServerProperties} but binds to the dedicated {@code resilience} resource
+ * bundle so that resilience tuning lives in its own file, separate from the
+ * server's functional configuration.
+ *
+ *
+ * @see ResilienceConfig
+ */
+class ResilienceProperties extends TypedSystemProperties
+{
+ private static final ResourceBundle RESOURCE_BUNDLE;
+
+
+ static
+ {
+ ResourceBundle bundle = null;
+
+ try
+ {
+ bundle = ResourceBundle.getBundle("resilience");
+ }
+ catch (MissingResourceException mre)
+ {
+ // The file is optional; built-in defaults will be used instead.
+ }
+
+ RESOURCE_BUNDLE = bundle;
+ }
+
+
+ @Override
+ public String getString(String key, String defaultValue)
+ {
+ if (key == null)
+ {
+ return defaultValue;
+ }
+
+ // A JVM system property always wins over the file.
+ if (super.contains(key))
+ {
+ return super.getString(key, defaultValue);
+ }
+
+ // The properties file is not available.
+ if (RESOURCE_BUNDLE == null)
+ {
+ return defaultValue;
+ }
+
+ try
+ {
+ return RESOURCE_BUNDLE.getString(key);
+ }
+ catch (MissingResourceException e)
+ {
+ return defaultValue;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java
new file mode 100644
index 0000000..fcb3b4c
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiFactory.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.lang.reflect.Proxy;
+import com.authlete.common.api.AuthleteApi;
+import com.authlete.common.api.AuthleteApiFactory;
+
+
+/**
+ * Drop-in replacement for {@link AuthleteApiFactory#getDefaultApi()} that
+ * returns an {@link AuthleteApi} wrapped with the resilience layer (caching,
+ * conditional retry, exponential backoff with jitter, and a circuit breaker).
+ *
+ *
+ * Endpoint classes obtain their API client through this factory instead of
+ * {@code AuthleteApiFactory} so that every call to Authlete — including
+ * nested calls made by handler SPIs that receive the same instance — is
+ * protected, with no change to the endpoint logic itself.
+ *
+ *
+ *
+ * The wrapped instance is built once and shared. Tuning lives in
+ * {@code resilience.properties} (see {@link ResilienceConfig}); when resilience
+ * is disabled there, the underlying default {@code AuthleteApi} is returned
+ * unwrapped, restoring the original behaviour.
+ *
+ */
+public final class ResilientAuthleteApiFactory
+{
+ private static volatile AuthleteApi cachedApi;
+
+
+ private ResilientAuthleteApiFactory()
+ {
+ }
+
+
+ /**
+ * Get the resilient default {@link AuthleteApi} instance.
+ *
+ * @return
+ * The default {@code AuthleteApi} wrapped with the resilience layer,
+ * or the unwrapped default instance when resilience is disabled.
+ */
+ public static AuthleteApi getDefaultApi()
+ {
+ AuthleteApi api = cachedApi;
+
+ if (api != null)
+ {
+ return api;
+ }
+
+ return initDefaultApi();
+ }
+
+
+ private static synchronized AuthleteApi initDefaultApi()
+ {
+ if (cachedApi != null)
+ {
+ return cachedApi;
+ }
+
+ AuthleteApi delegate = AuthleteApiFactory.getDefaultApi();
+
+ cachedApi = wrap(delegate);
+
+ return cachedApi;
+ }
+
+
+ /**
+ * Wrap the given {@link AuthleteApi} with the resilience layer. Returns the
+ * delegate unchanged when resilience is disabled in the configuration.
+ */
+ public static AuthleteApi wrap(AuthleteApi delegate)
+ {
+ ResilienceConfig config = new ResilienceConfig();
+
+ if (!config.isEnabled())
+ {
+ return delegate;
+ }
+
+ return (AuthleteApi) Proxy.newProxyInstance(
+ AuthleteApi.class.getClassLoader(),
+ new Class>[] { AuthleteApi.class },
+ new ResilientAuthleteApiInvocationHandler(delegate, config));
+ }
+}
diff --git a/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java
new file mode 100644
index 0000000..209d395
--- /dev/null
+++ b/src/main/java/com/authlete/jaxrs/server/resilience/ResilientAuthleteApiInvocationHandler.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import com.authlete.common.api.AuthleteApi;
+import com.authlete.common.api.AuthleteApiException;
+import com.authlete.common.dto.IntrospectionResponse;
+import com.authlete.jaxrs.server.resilience.AuthleteCacheableMethods.CachePolicy;
+
+
+/**
+ * The {@link InvocationHandler} behind the resilient {@link AuthleteApi} proxy.
+ *
+ *
+ * Every call to the wrapped {@code AuthleteApi} flows through {@link #invoke}
+ * which applies, in order, the four practices from Authlete's "Rate Limit Best
+ * Practices" guide:
+ *
+ *
+ * - Caching — a fresh cached response for an idempotent read is
+ * returned without calling Authlete.
+ * - Circuit breaking — when the per-method breaker is open, the
+ * call fails fast, serving stale cached data when available.
+ * - Conditional retry — only transient failures (429/5xx/no
+ * response) are retried; permanent 4xx errors propagate immediately.
+ * - Exponential backoff with jitter — the wait before each
+ * retry grows exponentially (honouring {@code RateLimit-Reset} on 429),
+ * bounded by a total retry budget.
+ *
+ */
+class ResilientAuthleteApiInvocationHandler implements InvocationHandler
+{
+ private static final Logger logger =
+ LoggerFactory.getLogger(ResilientAuthleteApiInvocationHandler.class);
+
+ private final AuthleteApi delegate;
+ private final AuthleteCacheableMethods cacheable;
+ private final AuthleteResponseCache cache;
+ private final AuthleteRetryPolicy retry;
+ private final AuthleteBackoff backoff;
+ private final AuthleteCircuitBreakerRegistry breakers;
+
+ private final boolean cacheEnabled;
+ private final boolean retryEnabled;
+ private final boolean breakerEnabled;
+ private final int maxAttempts;
+ private final long maxTotalMillis;
+
+
+ ResilientAuthleteApiInvocationHandler(AuthleteApi delegate, ResilienceConfig config)
+ {
+ this.delegate = delegate;
+ this.cacheable = new AuthleteCacheableMethods(config);
+ this.cache = new AuthleteResponseCache(config.getCacheStaleMillis(), config.getCacheMaxEntries());
+ this.retry = new AuthleteRetryPolicy();
+ this.backoff = new AuthleteBackoff(
+ config.getRetryBaseDelayMillis(),
+ config.getRetryMaxTotalMillis(),
+ config.getRetryJitterMillis());
+ this.breakers = new AuthleteCircuitBreakerRegistry(config);
+
+ this.cacheEnabled = config.isCacheEnabled();
+ this.retryEnabled = config.isRetryEnabled();
+ this.breakerEnabled = config.isBreakerEnabled();
+ this.maxAttempts = Math.max(1, config.getRetryMaxAttempts());
+ this.maxTotalMillis = config.getRetryMaxTotalMillis();
+ }
+
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
+ {
+ // Methods inherited from Object (equals/hashCode/toString) are handled
+ // locally and never forwarded to Authlete.
+ if (method.getDeclaringClass() == Object.class)
+ {
+ return invokeObjectMethod(proxy, method, args);
+ }
+
+ CachePolicy policy = cacheEnabled ? cacheable.policyFor(method, args) : null;
+
+ // (1) Serve a fresh cached response without touching the network.
+ if (policy != null)
+ {
+ Object fresh = cache.getFresh(policy.key);
+
+ if (fresh != null)
+ {
+ return fresh;
+ }
+ }
+
+ AuthleteCircuitBreaker breaker = breakerEnabled ? breakers.forMethod(method.getName()) : null;
+
+ long start = System.currentTimeMillis();
+ int attempt = 0;
+ AuthleteApiException lastError = null;
+
+ while (true)
+ {
+ attempt++;
+
+ // (2) Circuit breaker gate: fail fast when open.
+ if (breaker != null && !breaker.allowRequest())
+ {
+ Object stale = serveStale(policy, method, "circuit open");
+
+ if (stale != null)
+ {
+ return stale;
+ }
+
+ throw (lastError != null) ? lastError : circuitOpenException(method);
+ }
+
+ try
+ {
+ Object result = method.invoke(delegate, args);
+
+ if (breaker != null)
+ {
+ breaker.recordSuccess();
+ }
+
+ if (policy != null)
+ {
+ cache.put(policy.key, result, effectiveTtl(policy, result));
+ }
+
+ return result;
+ }
+ catch (InvocationTargetException ite)
+ {
+ Throwable cause = ite.getCause();
+
+ // Only AuthleteApiException participates in retry/breaker logic;
+ // anything else is an unexpected error and is propagated as-is,
+ // after releasing any half-open trial slot reserved by
+ // allowRequest() (the error says nothing about backend health).
+ if (!(cause instanceof AuthleteApiException))
+ {
+ if (breaker != null)
+ {
+ breaker.releaseTrial();
+ }
+
+ throw (cause != null) ? cause : ite;
+ }
+
+ AuthleteApiException ae = (AuthleteApiException) cause;
+ lastError = ae;
+
+ int status = ae.getStatusCode();
+ boolean isTransient = retry.isTransient(status);
+
+ // (3) Only transient failures count toward the breaker. A
+ // permanent 4xx proves the backend is up and answering, so it
+ // counts as a success (closing a half-open breaker and
+ // releasing the trial slot).
+ if (breaker != null)
+ {
+ if (isTransient)
+ {
+ breaker.recordFailure();
+ }
+ else
+ {
+ breaker.recordSuccess();
+ }
+ }
+
+ // (4) Retry transient failures with exponential backoff, within budget.
+ if (retryEnabled && isTransient && attempt < maxAttempts)
+ {
+ Long reset = (status == 429)
+ ? retry.rateLimitResetMillis(ae.getResponseHeaders()) : null;
+
+ long delay = backoff.delayMillis(attempt, reset);
+ long elapsed = System.currentTimeMillis() - start;
+
+ if (elapsed + delay <= maxTotalMillis)
+ {
+ logger.debug("Authlete API {} failed (status={}, attempt={}); retrying in {} ms.",
+ method.getName(), status, attempt, delay);
+
+ if (sleep(delay))
+ {
+ continue;
+ }
+ }
+ }
+
+ // Exhausted retries (or permanent error): try a stale fallback for
+ // transient failures, otherwise surface the original exception.
+ if (isTransient)
+ {
+ Object stale = serveStale(policy, method, "transient failure, retries exhausted");
+
+ if (stale != null)
+ {
+ return stale;
+ }
+ }
+
+ throw ae;
+ }
+ }
+ }
+
+
+ /**
+ * Compute the TTL to store a freshly fetched value under, capping
+ * introspection results so a cached entry never reports a token as active
+ * past its own expiry.
+ */
+ private long effectiveTtl(CachePolicy policy, Object result)
+ {
+ long ttl = policy.ttlMillis;
+
+ if (policy.capByTokenExpiry && result instanceof IntrospectionResponse)
+ {
+ long expiresAt = ((IntrospectionResponse) result).getExpiresAt();
+
+ if (expiresAt > 0)
+ {
+ long untilExpiry = expiresAt - System.currentTimeMillis();
+
+ // Already expired: do not cache at all.
+ if (untilExpiry <= 0)
+ {
+ return 0;
+ }
+
+ ttl = Math.min(ttl, untilExpiry);
+ }
+ }
+
+ return ttl;
+ }
+
+
+ private Object serveStale(CachePolicy policy, Method method, String reason)
+ {
+ if (policy == null)
+ {
+ return null;
+ }
+
+ Object stale = cache.getStale(policy.key);
+
+ if (stale != null)
+ {
+ logger.warn("Serving stale cached response for Authlete API {} ({}).",
+ method.getName(), reason);
+ }
+
+ return stale;
+ }
+
+
+ /**
+ * Sleep for the given duration. Returns {@code false} if interrupted, in
+ * which case the caller should stop retrying.
+ */
+ private boolean sleep(long millis)
+ {
+ if (millis <= 0)
+ {
+ return true;
+ }
+
+ try
+ {
+ Thread.sleep(millis);
+ return true;
+ }
+ catch (InterruptedException e)
+ {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+
+ private static AuthleteApiException circuitOpenException(Method method)
+ {
+ return new AuthleteApiException(
+ "Circuit breaker is open for Authlete API '" + method.getName()
+ + "'; failing fast to protect the service.",
+ 503, "Service Unavailable", null);
+ }
+
+
+ private Object invokeObjectMethod(Object proxy, Method method, Object[] args)
+ {
+ switch (method.getName())
+ {
+ case "equals":
+ return proxy == args[0];
+
+ case "hashCode":
+ return System.identityHashCode(proxy);
+
+ case "toString":
+ default:
+ return "ResilientAuthleteApi[" + delegate + "]";
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/resilience.properties b/src/main/resources/resilience.properties
new file mode 100644
index 0000000..b77c2ab
--- /dev/null
+++ b/src/main/resources/resilience.properties
@@ -0,0 +1,77 @@
+#
+# Resilience configuration for calls made to Authlete's API endpoints.
+#
+# This file tunes the resilience layer that wraps the Authlete API client
+# (see com.authlete.jaxrs.server.resilience). The layer implements the
+# practices described in Authlete's "Rate Limit Best Practices" guide:
+# intelligent caching of idempotent reads, conditional retries on transient
+# failures, exponential backoff with jitter, and a circuit breaker.
+#
+# Every key below can also be overridden with a JVM system property of the
+# same name (e.g. -Dresilience.cache.enabled=false), which always wins over
+# the value defined here. All durations are expressed in the unit noted on
+# each key. Removing a key simply falls back to the built-in default shown
+# in ResilienceConfig.
+#
+
+# ---------------------------------------------------------------------------
+# Master switch. When false, the Authlete API client is used directly with no
+# caching, retries or circuit breaking (i.e. the original behaviour).
+# ---------------------------------------------------------------------------
+resilience.enabled = true
+
+# ---------------------------------------------------------------------------
+# Caching of idempotent (safe, repeatable) read endpoints. TTLs are in seconds.
+# Only the endpoints listed in the best-practices guide are cached; all other
+# API calls are never cached. Keep the introspection TTLs short: a cached
+# introspection result keeps reporting a token as active until the entry
+# expires, even if the token was revoked in the meantime.
+# ---------------------------------------------------------------------------
+resilience.cache.enabled = true
+resilience.cache.ttl.serviceConfiguration = 600
+resilience.cache.ttl.serviceJwks = 600
+resilience.cache.ttl.client = 300
+resilience.cache.ttl.credentialIssuerMetadata = 600
+resilience.cache.ttl.credentialIssuerJwks = 600
+resilience.cache.ttl.introspection = 30
+resilience.cache.ttl.standardIntrospection = 30
+
+# How long an expired entry is retained as "stale" (seconds). Stale entries are
+# never served during normal operation; they are only used as a fast-fail
+# fallback while the circuit breaker for that endpoint is open.
+resilience.cache.staleSeconds = 1800
+
+# Safety cap on the number of cached entries (per cached method) to bound memory.
+resilience.cache.maxEntries = 10000
+
+# ---------------------------------------------------------------------------
+# Conditional retry with exponential backoff and jitter. Retries are attempted
+# only for transient failures: HTTP 429, 502, 503, any other 5xx, and
+# connection-level errors (no HTTP response). Permanent 4xx errors
+# (400/401/403/...) are never retried.
+# ---------------------------------------------------------------------------
+resilience.retry.enabled = true
+# Total number of attempts, including the first one (so 4 means 1 try + 3 retries).
+resilience.retry.maxAttempts = 4
+# Base delay for the first retry (ms). Subsequent retries double it: 500, 1000, 2000, ...
+resilience.retry.baseDelayMillis = 500
+# Hard cap on the total time spent retrying a single call (ms).
+resilience.retry.maxTotalMillis = 60000
+# Upper bound of the random jitter added to each backoff delay (ms).
+resilience.retry.jitterMillis = 200
+
+# ---------------------------------------------------------------------------
+# Circuit breaker. One independent breaker is kept per Authlete API method, so
+# a storm of failures on (say) client management does not trip the breaker for
+# introspection. While a breaker is open, calls fail fast (and serve stale
+# cached data when available) instead of hammering an unhealthy backend.
+# ---------------------------------------------------------------------------
+resilience.breaker.enabled = true
+# Number of transient failures within the window that trips the breaker open.
+resilience.breaker.failureThreshold = 5
+# Rolling window (seconds) over which failures are counted.
+resilience.breaker.windowSeconds = 30
+# How long the breaker stays open before allowing trial requests (seconds).
+resilience.breaker.openSeconds = 60
+# Number of trial requests allowed while half-open before deciding to close/reopen.
+resilience.breaker.halfOpenTrials = 1
\ No newline at end of file
diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java
new file mode 100644
index 0000000..c776ff7
--- /dev/null
+++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteBackoffTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import java.util.Random;
+import org.junit.Test;
+
+
+public class AuthleteBackoffTest
+{
+ @Test
+ public void exponentialScheduleWithoutJitter()
+ {
+ // base=500, max=60000, jitter=0
+ AuthleteBackoff backoff = new AuthleteBackoff(500, 60000, 0, new Random(0));
+
+ assertEquals(500, backoff.delayMillis(1, null)); // 500 * 2^0
+ assertEquals(1000, backoff.delayMillis(2, null)); // 500 * 2^1
+ assertEquals(2000, backoff.delayMillis(3, null)); // 500 * 2^2
+ assertEquals(4000, backoff.delayMillis(4, null)); // 500 * 2^3
+ }
+
+
+ @Test
+ public void delayIsCappedAtMax()
+ {
+ AuthleteBackoff backoff = new AuthleteBackoff(500, 3000, 0, new Random(0));
+
+ // 500 * 2^3 = 4000 would exceed the 3000 cap.
+ assertEquals(3000, backoff.delayMillis(4, null));
+ assertEquals(3000, backoff.delayMillis(20, null));
+ }
+
+
+ @Test
+ public void explicitDelayIsHonoured()
+ {
+ AuthleteBackoff backoff = new AuthleteBackoff(500, 60000, 0, new Random(0));
+
+ // A RateLimit-Reset of 5s overrides the exponential value.
+ assertEquals(5000, backoff.delayMillis(1, 5000L));
+ }
+
+
+ @Test
+ public void jitterStaysWithinBounds()
+ {
+ long base = 500;
+ long jitter = 200;
+ AuthleteBackoff backoff = new AuthleteBackoff(base, 60000, jitter, new Random(42));
+
+ for (int i = 0; i < 100; i++)
+ {
+ long delay = backoff.delayMillis(1, null);
+ assertTrue("delay >= base", delay >= base);
+ assertTrue("delay < base + jitter", delay < base + jitter);
+ }
+ }
+}
diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java
new file mode 100644
index 0000000..81b60c7
--- /dev/null
+++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCacheableMethodsTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import java.lang.reflect.Method;
+import java.net.URI;
+import org.junit.Test;
+import com.authlete.common.api.AuthleteApi;
+import com.authlete.common.dto.IntrospectionRequest;
+import com.authlete.common.dto.StandardIntrospectionRequest;
+import com.authlete.jaxrs.server.resilience.AuthleteCacheableMethods.CachePolicy;
+
+
+public class AuthleteCacheableMethodsTest
+{
+ private final AuthleteCacheableMethods cacheable =
+ new AuthleteCacheableMethods(new ResilienceConfig());
+
+
+ private CachePolicy introspectionPolicy(IntrospectionRequest req) throws Exception
+ {
+ Method method = AuthleteApi.class.getMethod(
+ "introspection", IntrospectionRequest.class);
+
+ return cacheable.policyFor(method, new Object[] { req });
+ }
+
+
+ private CachePolicy standardIntrospectionPolicy(StandardIntrospectionRequest req) throws Exception
+ {
+ Method method = AuthleteApi.class.getMethod(
+ "standardIntrospection", StandardIntrospectionRequest.class);
+
+ return cacheable.policyFor(method, new Object[] { req });
+ }
+
+
+ @Test
+ public void dpopIntrospectionIsNeverCached() throws Exception
+ {
+ IntrospectionRequest req = new IntrospectionRequest()
+ .setToken("token")
+ .setDpop("dpop-proof")
+ .setHtm("GET")
+ .setHtu("https://rs.example.com/resource");
+
+ assertNull("DPoP-bound requests must not be cached",
+ introspectionPolicy(req));
+ }
+
+
+ @Test
+ public void messageSignatureIntrospectionIsNeverCached() throws Exception
+ {
+ IntrospectionRequest req = new IntrospectionRequest()
+ .setToken("token")
+ .setMessage("signed-message");
+
+ assertNull("message-signature requests must not be cached",
+ introspectionPolicy(req));
+ }
+
+
+ @Test
+ public void clientCertificateParticipatesInIntrospectionKey() throws Exception
+ {
+ IntrospectionRequest base = new IntrospectionRequest().setToken("token");
+
+ CachePolicy noCert = introspectionPolicy(base);
+ CachePolicy withCert = introspectionPolicy(
+ new IntrospectionRequest().setToken("token").setClientCertificate("CERT-A"));
+ CachePolicy otherCert = introspectionPolicy(
+ new IntrospectionRequest().setToken("token").setClientCertificate("CERT-B"));
+
+ assertNotNull(noCert);
+ assertNotNull(withCert);
+ assertNotNull(otherCert);
+ assertNotEquals("same token, different certs must not share an entry",
+ withCert.key, otherCert.key);
+ assertNotEquals(noCert.key, withCert.key);
+ }
+
+
+ @Test
+ public void resourcesParticipateInIntrospectionKey() throws Exception
+ {
+ CachePolicy a = introspectionPolicy(new IntrospectionRequest()
+ .setToken("token")
+ .setResources(new URI[] { URI.create("https://rs-a.example.com") }));
+ CachePolicy b = introspectionPolicy(new IntrospectionRequest()
+ .setToken("token")
+ .setResources(new URI[] { URI.create("https://rs-b.example.com") }));
+
+ assertNotEquals(a.key, b.key);
+ }
+
+
+ @Test
+ public void rsUriParticipatesInStandardIntrospectionKey() throws Exception
+ {
+ CachePolicy a = standardIntrospectionPolicy(new StandardIntrospectionRequest()
+ .setParameters("token=abc")
+ .setRsUri(URI.create("https://rs-a.example.com")));
+ CachePolicy b = standardIntrospectionPolicy(new StandardIntrospectionRequest()
+ .setParameters("token=abc")
+ .setRsUri(URI.create("https://rs-b.example.com")));
+
+ assertNotEquals("different resource servers must not share an entry",
+ a.key, b.key);
+ }
+
+
+ @Test
+ public void acceptHeaderParticipatesInStandardIntrospectionKey() throws Exception
+ {
+ CachePolicy json = standardIntrospectionPolicy(new StandardIntrospectionRequest()
+ .setParameters("token=abc")
+ .setHttpAcceptHeader("application/json"));
+ CachePolicy jwt = standardIntrospectionPolicy(new StandardIntrospectionRequest()
+ .setParameters("token=abc")
+ .setHttpAcceptHeader("application/token-introspection+jwt"));
+
+ assertNotEquals(json.key, jwt.key);
+ }
+}
diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java
new file mode 100644
index 0000000..dc61329
--- /dev/null
+++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteCircuitBreakerTest.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import java.util.function.LongSupplier;
+import org.junit.Test;
+import com.authlete.jaxrs.server.resilience.AuthleteCircuitBreaker.State;
+
+
+public class AuthleteCircuitBreakerTest
+{
+ /** A clock whose value the test advances manually. */
+ private final long[] now = { 0L };
+ private final LongSupplier clock = () -> now[0];
+
+
+ private AuthleteCircuitBreaker newBreaker()
+ {
+ // threshold=3 failures within a 30s window, open for 60s, 1 half-open trial.
+ return new AuthleteCircuitBreaker(3, 30_000, 60_000, 1, clock);
+ }
+
+
+ @Test
+ public void opensAfterThresholdFailures()
+ {
+ AuthleteCircuitBreaker cb = newBreaker();
+
+ assertTrue(cb.allowRequest());
+ cb.recordFailure();
+ cb.recordFailure();
+ assertEquals(State.CLOSED, cb.getState());
+
+ cb.recordFailure(); // 3rd failure trips it open
+ assertEquals(State.OPEN, cb.getState());
+ assertFalse("open breaker fails fast", cb.allowRequest());
+ }
+
+
+ @Test
+ public void successResetsFailureCount()
+ {
+ AuthleteCircuitBreaker cb = newBreaker();
+
+ cb.recordFailure();
+ cb.recordFailure();
+ cb.recordSuccess(); // resets the count
+ cb.recordFailure();
+ cb.recordFailure();
+
+ assertEquals("still closed after reset", State.CLOSED, cb.getState());
+ }
+
+
+ @Test
+ public void failuresOutsideWindowDoNotAccumulate()
+ {
+ AuthleteCircuitBreaker cb = newBreaker();
+
+ cb.recordFailure();
+ cb.recordFailure();
+
+ // Advance beyond the 30s rolling window: the count restarts.
+ now[0] += 31_000;
+ cb.recordFailure();
+ cb.recordFailure();
+
+ assertEquals(State.CLOSED, cb.getState());
+ }
+
+
+ @Test
+ public void halfOpenSuccessClosesBreaker()
+ {
+ AuthleteCircuitBreaker cb = newBreaker();
+ trip(cb);
+
+ // Before the open timeout, requests are rejected.
+ now[0] += 30_000;
+ assertFalse(cb.allowRequest());
+
+ // After the open timeout, a single trial is allowed (half-open).
+ now[0] += 31_000;
+ assertTrue("half-open trial allowed", cb.allowRequest());
+ assertEquals(State.HALF_OPEN, cb.getState());
+ assertFalse("only one trial permitted", cb.allowRequest());
+
+ cb.recordSuccess();
+ assertEquals("success closes the breaker", State.CLOSED, cb.getState());
+ }
+
+
+ @Test
+ public void halfOpenFailureReopensBreaker()
+ {
+ AuthleteCircuitBreaker cb = newBreaker();
+ trip(cb);
+
+ now[0] += 61_000;
+ assertTrue(cb.allowRequest()); // half-open trial
+ cb.recordFailure();
+
+ assertEquals("failed trial reopens", State.OPEN, cb.getState());
+ assertFalse(cb.allowRequest());
+ }
+
+
+ @Test
+ public void releaseTrialFreesHalfOpenSlot()
+ {
+ AuthleteCircuitBreaker cb = newBreaker();
+ trip(cb);
+
+ now[0] += 61_000;
+ assertTrue(cb.allowRequest()); // half-open trial reserved
+ assertFalse("slot taken", cb.allowRequest());
+
+ // The trial ended without a verdict on backend health (e.g. an
+ // unexpected local error): the slot must become available again.
+ cb.releaseTrial();
+ assertEquals("still half-open", State.HALF_OPEN, cb.getState());
+ assertTrue("slot available again", cb.allowRequest());
+ }
+
+
+ private void trip(AuthleteCircuitBreaker cb)
+ {
+ cb.recordFailure();
+ cb.recordFailure();
+ cb.recordFailure();
+ assertEquals(State.OPEN, cb.getState());
+ }
+}
diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java
new file mode 100644
index 0000000..54cdc2f
--- /dev/null
+++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteResponseCacheTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import java.util.function.LongSupplier;
+import org.junit.Test;
+
+
+public class AuthleteResponseCacheTest
+{
+ private final long[] now = { 0L };
+ private final LongSupplier clock = () -> now[0];
+
+
+ @Test
+ public void freshHitWithinTtl()
+ {
+ AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock);
+ cache.put("k", "v", 500);
+
+ now[0] = 499;
+ assertEquals("v", cache.getFresh("k"));
+ }
+
+
+ @Test
+ public void expiresAfterTtlButStaleRemains()
+ {
+ AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock);
+ cache.put("k", "v", 500); // fresh until 500, stale until 1500
+
+ now[0] = 600;
+ assertNull("no longer fresh", cache.getFresh("k"));
+ assertEquals("but available as stale", "v", cache.getStale("k"));
+ }
+
+
+ @Test
+ public void staleEntryDroppedAfterStaleWindow()
+ {
+ AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock);
+ cache.put("k", "v", 500); // stale until 1500
+
+ now[0] = 1600;
+ assertNull(cache.getStale("k"));
+ assertNull(cache.getFresh("k"));
+ }
+
+
+ @Test
+ public void nullValueAndNonPositiveTtlAreNotCached()
+ {
+ AuthleteResponseCache cache = new AuthleteResponseCache(1000, 100, clock);
+ cache.put("a", null, 500);
+ cache.put("b", "v", 0);
+
+ assertNull(cache.getFresh("a"));
+ assertNull(cache.getFresh("b"));
+ assertEquals(0, cache.size());
+ }
+
+
+ @Test
+ public void maxEntriesIsBounded()
+ {
+ AuthleteResponseCache cache = new AuthleteResponseCache(1000, 2, clock);
+ cache.put("a", "1", 500);
+ cache.put("b", "2", 500);
+ cache.put("c", "3", 500); // exceeds the limit; rejected while others are live
+
+ assertEquals(2, cache.size());
+ assertNull("new key rejected at capacity", cache.getFresh("c"));
+
+ // Updating an existing key is still allowed at capacity.
+ cache.put("a", "1b", 500);
+ assertEquals("1b", cache.getFresh("a"));
+ }
+}
diff --git a/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java
new file mode 100644
index 0000000..8d2fdc3
--- /dev/null
+++ b/src/test/java/com/authlete/jaxrs/server/resilience/AuthleteRetryPolicyTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 Authlete, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package com.authlete.jaxrs.server.resilience;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+
+
+public class AuthleteRetryPolicyTest
+{
+ private final AuthleteRetryPolicy policy = new AuthleteRetryPolicy();
+
+
+ @Test
+ public void transientStatuses()
+ {
+ assertTrue("no response is transient", policy.isTransient(0));
+ assertTrue("429 is transient", policy.isTransient(429));
+ assertTrue("500 is transient", policy.isTransient(500));
+ assertTrue("502 is transient", policy.isTransient(502));
+ assertTrue("503 is transient", policy.isTransient(503));
+ assertTrue("599 is transient", policy.isTransient(599));
+ }
+
+
+ @Test
+ public void permanentStatuses()
+ {
+ assertFalse("400 is permanent", policy.isTransient(400));
+ assertFalse("401 is permanent", policy.isTransient(401));
+ assertFalse("403 is permanent", policy.isTransient(403));
+ assertFalse("404 is permanent", policy.isTransient(404));
+ assertFalse("200 is not retried", policy.isTransient(200));
+ }
+
+
+ @Test
+ public void rateLimitResetParsedFromSeconds()
+ {
+ Map> headers = new HashMap<>();
+ headers.put("RateLimit-Reset", Collections.singletonList("3"));
+
+ assertEquals(Long.valueOf(3000L), policy.rateLimitResetMillis(headers));
+ }
+
+
+ @Test
+ public void rateLimitResetHeaderIsCaseInsensitive()
+ {
+ Map> headers = new HashMap<>();
+ headers.put("ratelimit-reset", Arrays.asList("2"));
+
+ assertEquals(Long.valueOf(2000L), policy.rateLimitResetMillis(headers));
+ }
+
+
+ @Test
+ public void rateLimitResetAbsentOrInvalid()
+ {
+ assertNull(policy.rateLimitResetMillis(null));
+ assertNull(policy.rateLimitResetMillis(new HashMap<>()));
+
+ Map> bad = new HashMap<>();
+ bad.put("RateLimit-Reset", Collections.singletonList("not-a-number"));
+ assertNull(policy.rateLimitResetMillis(bad));
+
+ Map> zero = new HashMap<>();
+ zero.put("RateLimit-Reset", Collections.singletonList("0"));
+ assertNull("non-positive reset is ignored", policy.rateLimitResetMillis(zero));
+ }
+}