Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 59 additions & 19 deletions msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,20 +123,43 @@ IHttpResponse executeHttpRequest(HttpRequest httpRequest) {
return httpResponse;
}

/*
* Two throttle fingerprints are derived from a request:
*
* - The app-wide thumbprint (includeUser == false) keys on clientId + authority + scope only.
* It is used for service-directed rate limiting (HTTP 429), which applies to the whole client
* regardless of which user made the request.
*
* - The user-aware thumbprint (includeUser == true) additionally folds in the request's user
* component (OID, else UPN). It is used for error-class throttling (HTTP 5xx), which can be
* specific to a single user (e.g. ADFS returns HTTP 500 for one user's bad password) and must
* not block other users of the same client.
*
* An explicit Retry-After header only affects how long an entry is throttled, not which of the
* two fingerprints is used (that is decided by the response status class).
*/
private String getRequestThumbprint(RequestContext requestContext) {
StringBuilder sb = new StringBuilder();
sb.append(requestContext.clientId() + POINT_DELIMITER);
sb.append(requestContext.authority() + POINT_DELIMITER);

IAcquireTokenParameters apiParameters = requestContext.apiParameters();
return getRequestThumbprint(requestContext, true);
}

if (apiParameters instanceof SilentParameters) {
IAccount account = ((SilentParameters) apiParameters).account();
if (account != null) {
sb.append(account.homeAccountId() + POINT_DELIMITER);
private String getRequestThumbprint(RequestContext requestContext, boolean includeUser) {
StringBuilder sb = new StringBuilder();
sb.append(requestContext.clientId()).append(POINT_DELIMITER);
sb.append(requestContext.authority()).append(POINT_DELIMITER);

if (includeUser) {
UserIdentifier userIdentifier = requestContext.userIdentifier();
if (userIdentifier != null) {
// Prefer OID: it is the stable, guaranteed-unique user identifier
if (!StringHelper.isBlank(userIdentifier.oid())) {
sb.append(userIdentifier.oid()).append(POINT_DELIMITER);
} else if (!StringHelper.isBlank(userIdentifier.upn())) {
sb.append(userIdentifier.upn()).append(POINT_DELIMITER);
}
}
}

IAcquireTokenParameters apiParameters = requestContext.apiParameters();
Set<String> sortedScopes = new TreeSet<>(apiParameters.scopes());
sb.append(String.join(" ", sortedScopes));

Expand Down Expand Up @@ -168,9 +191,17 @@ IHttpResponse executeHttpRequestWithRetries(HttpRequest httpRequest, IHttpClient
private void checkForThrottling(RequestContext requestContext) {
if (requestContext.clientApplication() instanceof PublicClientApplication &&
requestContext.apiParameters() != null) {
String requestThumbprint = getRequestThumbprint(requestContext);

long retryInMs = ThrottlingCache.retryInMs(requestThumbprint);
// Check the app-wide key first (429 / Retry-After entries), then the user-aware key
// (5xx entries) when it differs from the app-wide key.
String appWideThumbprint = getRequestThumbprint(requestContext, false);
long retryInMs = ThrottlingCache.retryInMs(appWideThumbprint);

if (retryInMs <= 0) {
String userAwareThumbprint = getRequestThumbprint(requestContext);
if (!userAwareThumbprint.equals(appWideThumbprint)) {
retryInMs = ThrottlingCache.retryInMs(userAwareThumbprint);
}
}
Comment thread
Copilot marked this conversation as resolved.

if (retryInMs > 0) {
throw new MsalThrottlingException(retryInMs);
Expand All @@ -181,17 +212,26 @@ private void checkForThrottling(RequestContext requestContext) {
private void processThrottlingInstructions(IHttpResponse httpResponse, RequestContext requestContext) {
if (requestContext.clientApplication() instanceof PublicClientApplication) {
Long expirationTimestamp = null;
// Scope is determined by the status class, not by the presence of a Retry-After header:
// 5xx errors can be user-specific (e.g. ADFS returns HTTP 500 for one user's bad
// password), so they are throttled per-user; HTTP 429 is service-directed and is
// throttled app-wide. An explicit Retry-After header only overrides the throttle
// *duration*, leaving the scope decision to the status code.
boolean userScoped = false;

Integer retryAfterHeaderVal = getRetryAfterHeader(httpResponse);
if (retryAfterHeaderVal != null) {
expirationTimestamp = System.currentTimeMillis() + retryAfterHeaderVal * 1000;
} else if (httpResponse.statusCode() == HttpStatus.HTTP_TOO_MANY_REQUESTS ||
(httpResponse.statusCode() >= HttpStatus.HTTP_INTERNAL_ERROR)) {

expirationTimestamp = System.currentTimeMillis() + ThrottlingCache.DEFAULT_THROTTLING_TIME_SEC * 1000;
boolean isServerError = httpResponse.statusCode() >= HttpStatus.HTTP_INTERNAL_ERROR;
boolean isTooManyRequests = httpResponse.statusCode() == HttpStatus.HTTP_TOO_MANY_REQUESTS;

if (retryAfterHeaderVal != null || isTooManyRequests || isServerError) {
int throttleDurationSec = retryAfterHeaderVal != null
? retryAfterHeaderVal
: ThrottlingCache.DEFAULT_THROTTLING_TIME_SEC;
expirationTimestamp = System.currentTimeMillis() + throttleDurationSec * 1000;
userScoped = isServerError;
}
if (expirationTimestamp != null) {
ThrottlingCache.set(getRequestThumbprint(requestContext), expirationTimestamp);
ThrottlingCache.set(getRequestThumbprint(requestContext, userScoped), expirationTimestamp);
}
}
}
Expand Down
Loading
Loading