Skip to content

Commit c8126be

Browse files
authored
[core] Retry on 503 using the Retry-After header (Azure#15906)
Based on the Retry-After specification, 503 should also be supported when considering the Retry-After header. This also aligns with upcoming Identity plans.
1 parent 9699830 commit c8126be

File tree

13 files changed

+350
-29
lines changed

13 files changed

+350
-29
lines changed

sdk/core/core-http/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
### Features Added
66

7-
- Changed TS compilation target to ES2017 in order to produce smaller bundles and use more native platform features
7+
- Changed TS compilation target to ES2017 in order to produce smaller bundles and use more native platform features.
8+
- Added support for the `Retry-After` header on responses with status code 503, Service Unavailable.
9+
- Added support for multiple retries on the `ThrottlingRetryPolicy` (up to 3 by default).
810

911
### Breaking Changes
1012

sdk/core/core-http/review/core-http.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export const Constants: {
145145
};
146146
StatusCodes: {
147147
TooManyRequests: number;
148+
ServiceUnavailable: number;
148149
};
149150
};
150151
HeaderConstants: {

sdk/core/core-http/src/policies/exponentialRetryPolicy.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "../util/exponentialBackoffStrategy";
2222
import { RestError } from "../restError";
2323
import { logger } from "../log";
24+
import { Constants } from "../util/constants";
2425
import { delay } from "../util/delay";
2526

2627
export function exponentialRetryPolicy(
@@ -139,6 +140,10 @@ async function retry(
139140
): Promise<HttpOperationResponse> {
140141
function shouldPolicyRetry(responseParam?: HttpOperationResponse): boolean {
141142
const statusCode = responseParam?.status;
143+
if (statusCode === 503 && response?.headers.get(Constants.HeaderConstants.RETRY_AFTER)) {
144+
return false;
145+
}
146+
142147
if (
143148
statusCode === undefined ||
144149
(statusCode < 500 && statusCode !== 408) ||

sdk/core/core-http/src/policies/throttlingRetryPolicy.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4+
import { AbortError } from "@azure/abort-controller";
45
import {
56
BaseRequestPolicy,
67
RequestPolicy,
@@ -10,8 +11,8 @@ import {
1011
import { WebResourceLike } from "../webResource";
1112
import { HttpOperationResponse } from "../httpOperationResponse";
1213
import { Constants } from "../util/constants";
14+
import { DEFAULT_CLIENT_MAX_RETRY_COUNT } from "../util/throttlingRetryStrategy";
1315
import { delay } from "../util/delay";
14-
import { AbortError } from "@azure/abort-controller";
1516

1617
type ResponseHandler = (
1718
httpRequest: WebResourceLike,
@@ -37,6 +38,7 @@ const StandardAbortMessage = "The operation was aborted.";
3738
*/
3839
export class ThrottlingRetryPolicy extends BaseRequestPolicy {
3940
private _handleResponse: ResponseHandler;
41+
private numberOfRetries = 0;
4042

4143
constructor(
4244
nextPolicy: RequestPolicy,
@@ -48,13 +50,15 @@ export class ThrottlingRetryPolicy extends BaseRequestPolicy {
4850
}
4951

5052
public async sendRequest(httpRequest: WebResourceLike): Promise<HttpOperationResponse> {
51-
return this._nextPolicy.sendRequest(httpRequest.clone()).then((response) => {
52-
if (response.status !== StatusCodes.TooManyRequests) {
53-
return response;
54-
} else {
55-
return this._handleResponse(httpRequest, response);
56-
}
57-
});
53+
const response = await this._nextPolicy.sendRequest(httpRequest.clone());
54+
if (
55+
response.status !== StatusCodes.TooManyRequests &&
56+
response.status !== StatusCodes.ServiceUnavailable
57+
) {
58+
return response;
59+
} else {
60+
return this._handleResponse(httpRequest, response);
61+
}
5862
}
5963

6064
private async _defaultResponseHandler(
@@ -70,14 +74,22 @@ export class ThrottlingRetryPolicy extends BaseRequestPolicy {
7074
retryAfterHeader
7175
);
7276
if (delayInMs) {
77+
this.numberOfRetries += 1;
78+
7379
await delay(delayInMs, undefined, {
7480
abortSignal: httpRequest.abortSignal,
7581
abortErrorMsg: StandardAbortMessage
7682
});
83+
7784
if (httpRequest.abortSignal?.aborted) {
7885
throw new AbortError(StandardAbortMessage);
7986
}
80-
return this._nextPolicy.sendRequest(httpRequest);
87+
88+
if (this.numberOfRetries < DEFAULT_CLIENT_MAX_RETRY_COUNT) {
89+
return this.sendRequest(httpRequest);
90+
} else {
91+
return this._nextPolicy.sendRequest(httpRequest);
92+
}
8193
}
8294
}
8395

sdk/core/core-http/src/util/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export const Constants = {
5252
},
5353

5454
StatusCodes: {
55-
TooManyRequests: 429
55+
TooManyRequests: 429,
56+
ServiceUnavailable: 503
5657
}
5758
},
5859

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
/**
5+
* Maximum number of retries for the throttling retry policy
6+
*/
7+
export const DEFAULT_CLIENT_MAX_RETRY_COUNT = 3;

sdk/core/core-http/test/policies/throttlingRetryPolicyTests.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe("ThrottlingRetryPolicy", () => {
7272
assert.deepEqual(response.request, request);
7373
});
7474

75-
it("should do nothing when status code is not 429", async () => {
75+
it("should do nothing when status code is not 429 nor 503", async () => {
7676
const request = new WebResource();
7777
const mockResponse = {
7878
status: 400,
@@ -114,6 +114,120 @@ describe("ThrottlingRetryPolicy", () => {
114114
assert.deepEqual(response, mockResponse);
115115
});
116116

117+
it("should pass the response to the handler if the status code equals 503", async () => {
118+
const request = new WebResource();
119+
const mockResponse = {
120+
status: 503,
121+
headers: new HttpHeaders({
122+
"Retry-After": "100"
123+
}),
124+
request: request
125+
};
126+
const policy = createDefaultThrottlingRetryPolicy(mockResponse, (_, response) => {
127+
delete (response.request as any).requestId;
128+
delete (mockResponse.request as any).requestId;
129+
assert.deepEqual(response, mockResponse);
130+
return Promise.resolve(response);
131+
});
132+
133+
const response = await policy.sendRequest(request);
134+
delete (request as any).requestId;
135+
delete (response.request as any).requestId;
136+
assert.deepEqual(response, mockResponse);
137+
});
138+
139+
it("if the status code equals 429, it should retry up to 3 times", async () => {
140+
const request = new WebResource();
141+
const status = 429;
142+
const retryResponse = {
143+
status,
144+
headers: new HttpHeaders({
145+
"Retry-After": "1"
146+
}),
147+
request
148+
};
149+
const responses: HttpOperationResponse[] = [
150+
retryResponse,
151+
retryResponse,
152+
retryResponse,
153+
// This one should be returned
154+
{
155+
status,
156+
headers: new HttpHeaders({
157+
"Retry-After": "1",
158+
"final-response": "final-response"
159+
}),
160+
request
161+
}
162+
];
163+
164+
const clock = sinon.useFakeTimers();
165+
166+
const policy = new ThrottlingRetryPolicy(
167+
{
168+
async sendRequest(): Promise<HttpOperationResponse> {
169+
return responses.shift()!;
170+
}
171+
},
172+
new RequestPolicyOptions()
173+
);
174+
175+
const promise = policy.sendRequest(request);
176+
clock.tickAsync(3000);
177+
178+
const response = await promise;
179+
assert.deepEqual(response.status, status);
180+
assert.equal(response.headers.get("final-response"), "final-response");
181+
182+
clock.restore();
183+
});
184+
185+
it("if the status code equals 503, it should retry up to 3 times", async () => {
186+
const request = new WebResource();
187+
const status = 503;
188+
const retryResponse = {
189+
status,
190+
headers: new HttpHeaders({
191+
"Retry-After": "1"
192+
}),
193+
request
194+
};
195+
const responses: HttpOperationResponse[] = [
196+
retryResponse,
197+
retryResponse,
198+
retryResponse,
199+
// This one should be returned
200+
{
201+
status,
202+
headers: new HttpHeaders({
203+
"Retry-After": "1",
204+
"final-response": "final-response"
205+
}),
206+
request
207+
}
208+
];
209+
210+
const clock = sinon.useFakeTimers();
211+
212+
const policy = new ThrottlingRetryPolicy(
213+
{
214+
async sendRequest(): Promise<HttpOperationResponse> {
215+
return responses.shift()!;
216+
}
217+
},
218+
new RequestPolicyOptions()
219+
);
220+
221+
const promise = policy.sendRequest(request);
222+
clock.tickAsync(3000);
223+
224+
const response = await promise;
225+
assert.deepEqual(response.status, status);
226+
assert.equal(response.headers.get("final-response"), "final-response");
227+
228+
clock.restore();
229+
});
230+
117231
it("should honor the abort signal passed", async () => {
118232
const request = new WebResource(
119233
"https://fakeservice.io",

sdk/core/core-rest-pipeline/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
- Fixed an issue where `proxySettings` does not work when there is username but no password [Issue 15720](https://github.com/Azure/azure-sdk-for-js/issues/15720)
88

9+
### Features Added
10+
11+
- Added support for the `Retry-After` header on responses with status code 503, Service Unavailable.
12+
- The `ExponentialRetryPolicy` will now ignore `503` responses if they have the `Retry-After` header.
13+
- Added support for multiple retries on the `ThrottlingRetryPolicy` (up to 3 by default).
14+
915
### Breaking Changes
1016

1117
- Updated @azure/core-tracing to version `1.0.0-preview.12`. See [@azure/core-tracing CHANGELOG](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/core/core-tracing/CHANGELOG.md) for details about breaking changes with tracing.

sdk/core/core-rest-pipeline/src/policies/exponentialRetryPolicy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,12 @@ export function exponentialRetryPolicy(
7070
* @param retryData - The retry data.
7171
* @returns True if the operation qualifies for a retry; false otherwise.
7272
*/
73-
function shouldRetry(statusCode: number | undefined, retryData: RetryData): boolean {
73+
function shouldRetry(response: PipelineResponse | undefined, retryData: RetryData): boolean {
74+
const statusCode = response?.status;
75+
if (statusCode === 503 && response?.headers.get("Retry-After")) {
76+
return false;
77+
}
78+
7479
if (
7580
statusCode === undefined ||
7681
(statusCode < 500 && statusCode !== 408) ||
@@ -126,7 +131,7 @@ export function exponentialRetryPolicy(
126131
): Promise<PipelineResponse> {
127132
retryData = updateRetryData(retryData, requestError);
128133
const isAborted = request.abortSignal?.aborted;
129-
if (!isAborted && shouldRetry(response?.status, retryData)) {
134+
if (!isAborted && shouldRetry(response, retryData)) {
130135
logger.info(`Retrying request in ${retryData.retryInterval}`);
131136
try {
132137
await delay(retryData.retryInterval);

sdk/core/core-rest-pipeline/src/policies/throttlingRetryPolicy.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { delay } from "../util/helpers";
1010
*/
1111
export const throttlingRetryPolicyName = "throttlingRetryPolicy";
1212

13+
/**
14+
* Maximum number of retries for the throttling retry policy
15+
*/
16+
export const DEFAULT_CLIENT_MAX_RETRY_COUNT = 3;
17+
1318
/**
1419
* A policy that retries when the server sends a 429 response with a Retry-After header.
1520
*
@@ -22,21 +27,23 @@ export function throttlingRetryPolicy(): PipelinePolicy {
2227
return {
2328
name: throttlingRetryPolicyName,
2429
async sendRequest(request: PipelineRequest, next: SendRequest): Promise<PipelineResponse> {
25-
const response = await next(request);
26-
if (response.status !== 429) {
27-
return response;
28-
}
30+
let response = await next(request);
2931

30-
const retryAfterHeader = response.headers.get("Retry-After");
31-
32-
if (retryAfterHeader) {
32+
for (let count = 0; count < DEFAULT_CLIENT_MAX_RETRY_COUNT; count++) {
33+
if (response.status !== 429 && response.status !== 503) {
34+
return response;
35+
}
36+
const retryAfterHeader = response.headers.get("Retry-After");
37+
if (!retryAfterHeader) {
38+
break;
39+
}
3340
const delayInMs = parseRetryAfterHeader(retryAfterHeader);
34-
if (delayInMs) {
35-
await delay(delayInMs);
36-
return next(request);
41+
if (!delayInMs) {
42+
break;
3743
}
44+
await delay(delayInMs);
45+
response = await next(request);
3846
}
39-
4047
return response;
4148
}
4249
};

0 commit comments

Comments
 (0)