Skip to content

Commit bb9896d

Browse files
[core-http] Throttling retry policy fix in core-http (Azure#15832)
Fixes Azure#15796 ## Problem The throttlingRetryPolicy in core-http has the potential to retry for an extended period if the service continues returning "retry after" headers on subsequent calls. Here's the snippet of code that handles the "retry after" retries: ```typescript public async sendRequest(httpRequest: WebResource): Promise<HttpOperationResponse> { return this._nextPolicy.sendRequest(httpRequest.clone()).catch((err) => { // other code elided.... return delay(delayInMs).then((_: any) => this.sendRequest(httpRequest.clone())); ``` ## Solution Update delay such that it respects abort signal. Similar to what I had to do for app-config at Azure#15721
1 parent 35739ab commit bb9896d

File tree

13 files changed

+156
-93
lines changed

13 files changed

+156
-93
lines changed

sdk/appconfiguration/app-configuration/src/policies/throttlingRetryPolicy.ts

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

4-
import { AbortError, AbortSignalLike } from "@azure/abort-controller";
4+
import { AbortError } from "@azure/abort-controller";
55
import {
66
BaseRequestPolicy,
77
RequestPolicy,
@@ -12,7 +12,7 @@ import {
1212
Constants,
1313
RestError
1414
} from "@azure/core-http";
15-
import { isDefined } from "../internal/typeguards";
15+
import { delay } from "@azure/core-http";
1616

1717
/**
1818
* @internal
@@ -27,55 +27,6 @@ export function throttlingRetryPolicy(): RequestPolicyFactory {
2727

2828
const StandardAbortMessage = "The operation was aborted.";
2929

30-
/**
31-
* A wrapper for setTimeout that resolves a promise after t milliseconds.
32-
* @param delayInMs - The number of milliseconds to be delayed.
33-
* @param abortSignal - The abortSignal associated with containing operation.
34-
* @param abortErrorMsg - The abort error message associated with containing operation.
35-
* @returns - Resolved promise
36-
*/
37-
export function delay(
38-
delayInMs: number,
39-
abortSignal?: AbortSignalLike,
40-
abortErrorMsg?: string
41-
): Promise<void> {
42-
return new Promise((resolve, reject) => {
43-
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
44-
let onAborted: (() => void) | undefined = undefined;
45-
46-
const rejectOnAbort = (): void => {
47-
return reject(new AbortError(abortErrorMsg ? abortErrorMsg : StandardAbortMessage));
48-
};
49-
50-
const removeListeners = (): void => {
51-
if (abortSignal && onAborted) {
52-
abortSignal.removeEventListener("abort", onAborted);
53-
}
54-
};
55-
56-
onAborted = (): void => {
57-
if (isDefined(timer)) {
58-
clearTimeout(timer);
59-
}
60-
removeListeners();
61-
return rejectOnAbort();
62-
};
63-
64-
if (abortSignal && abortSignal.aborted) {
65-
return rejectOnAbort();
66-
}
67-
68-
timer = setTimeout(() => {
69-
removeListeners();
70-
resolve();
71-
}, delayInMs);
72-
73-
if (abortSignal) {
74-
abortSignal.addEventListener("abort", onAborted);
75-
}
76-
});
77-
}
78-
7930
/**
8031
* This policy is a close copy of the ThrottlingRetryPolicy class from
8132
* core-http with modifications to work with how AppConfig is currently
@@ -97,7 +48,10 @@ export class ThrottlingRetryPolicy extends BaseRequestPolicy {
9748
throw err;
9849
}
9950

100-
await delay(delayInMs, httpRequest.abortSignal, StandardAbortMessage);
51+
await delay(delayInMs, undefined, {
52+
abortSignal: httpRequest.abortSignal,
53+
abortErrorMsg: StandardAbortMessage
54+
});
10155
if (httpRequest.abortSignal?.aborted) {
10256
throw new AbortError(StandardAbortMessage);
10357
}

sdk/core/core-http/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
### Fixed
1616

1717
- 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)
18+
- Throttling retry policy respects abort signal [#15796](https://github.com/Azure/azure-sdk-for-js/issues/15796)
1819

1920
## 1.2.6 (2021-06-14)
2021

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,10 @@ export class DefaultHttpClient extends FetchHttpClient {
181181
}
182182

183183
// @public
184-
export function delay<T>(t: number, value?: T): Promise<T | void>;
184+
export function delay<T>(delayInMs: number, value?: T, options?: {
185+
abortSignal?: AbortSignalLike;
186+
abortErrorMsg?: string;
187+
}): Promise<T | void>;
185188

186189
// @public
187190
export interface DeserializationContentTypes {

sdk/core/core-http/src/coreHttp.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ export {
9999
export {
100100
stripRequest,
101101
stripResponse,
102-
delay,
103102
executePromisesSequentially,
104103
generateUuid,
105104
encodeUri,
@@ -113,7 +112,7 @@ export {
113112
} from "./util/utils";
114113
export { URLBuilder, URLQuery } from "./url";
115114
export { AbortSignalLike } from "@azure/abort-controller";
116-
115+
export { delay } from "./util/delay";
117116
// legacy exports. Use core-tracing instead (and remove on next major version update of core-http).
118117
export { createSpanFunction, SpanConfig } from "./createSpanLegacy";
119118

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { Constants } from "../util/constants";
1212
import { HttpOperationResponse } from "../httpOperationResponse";
1313
import { WebResourceLike } from "../webResource";
14-
import { delay } from "../util/utils";
14+
import { delay } from "../util/delay";
1515

1616
// #region Access Token Cycler
1717

@@ -71,7 +71,7 @@ async function beginRefresh(
7171
): Promise<AccessToken> {
7272
// This wrapper handles exceptions gracefully as long as we haven't exceeded
7373
// the timeout.
74-
async function tryGetAccessToken() {
74+
async function tryGetAccessToken(): Promise<AccessToken | null> {
7575
if (Date.now() < timeoutInMs) {
7676
try {
7777
return await getAccessToken();

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the MIT license.
33

44
import { HttpOperationResponse } from "../httpOperationResponse";
5-
import * as utils from "../util/utils";
65
import { WebResourceLike } from "../webResource";
76
import {
87
BaseRequestPolicy,
@@ -22,6 +21,7 @@ import {
2221
} from "../util/exponentialBackoffStrategy";
2322
import { RestError } from "../restError";
2423
import { logger } from "../log";
24+
import { delay } from "../util/delay";
2525

2626
export function exponentialRetryPolicy(
2727
retryCount?: number,
@@ -164,7 +164,7 @@ async function retry(
164164
if (!isAborted && shouldRetry(policy.retryCount, shouldPolicyRetry, retryData, response)) {
165165
logger.info(`Retrying request in ${retryData.retryInterval}`);
166166
try {
167-
await utils.delay(retryData.retryInterval);
167+
await delay(retryData.retryInterval);
168168
const res = await policy._nextPolicy.sendRequest(request.clone());
169169
return retry(policy, request, res, retryData);
170170
} catch (err) {

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

Lines changed: 16 additions & 19 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 { delay } from "../util/delay";
45
import { HttpOperationResponse } from "../httpOperationResponse";
56
import * as utils from "../util/utils";
67
import { WebResourceLike } from "../webResource";
@@ -145,9 +146,8 @@ function extractSubscriptionUrl(url: string): string {
145146
* @param provider - The provider name to be registered.
146147
* @param originalRequest - The original request sent by the user that returned a 409 response
147148
* with a message that the provider is not registered.
148-
* @param callback - The callback that handles the RP registration
149149
*/
150-
function registerRP(
150+
async function registerRP(
151151
policy: RPRegistrationPolicy,
152152
urlPrefix: string,
153153
provider: string,
@@ -159,12 +159,11 @@ function registerRP(
159159
reqOptions.method = "POST";
160160
reqOptions.url = postUrl;
161161

162-
return policy._nextPolicy.sendRequest(reqOptions).then((response) => {
163-
if (response.status !== 200) {
164-
throw new Error(`Autoregistration of ${provider} failed. Please try registering manually.`);
165-
}
166-
return getRegistrationStatus(policy, getUrl, originalRequest);
167-
});
162+
const response = await policy._nextPolicy.sendRequest(reqOptions);
163+
if (response.status !== 200) {
164+
throw new Error(`Autoregistration of ${provider} failed. Please try registering manually.`);
165+
}
166+
return getRegistrationStatus(policy, getUrl, originalRequest);
168167
}
169168

170169
/**
@@ -176,7 +175,7 @@ function registerRP(
176175
* with a message that the provider is not registered.
177176
* @returns True if RP Registration is successful.
178177
*/
179-
function getRegistrationStatus(
178+
async function getRegistrationStatus(
180179
policy: RPRegistrationPolicy,
181180
url: string,
182181
originalRequest: WebResourceLike
@@ -185,14 +184,12 @@ function getRegistrationStatus(
185184
reqOptions.url = url;
186185
reqOptions.method = "GET";
187186

188-
return policy._nextPolicy.sendRequest(reqOptions).then((res) => {
189-
const obj = res.parsedBody as any;
190-
if (res.parsedBody && obj.registrationState && obj.registrationState === "Registered") {
191-
return true;
192-
} else {
193-
return utils
194-
.delay(policy._retryTimeout * 1000)
195-
.then(() => getRegistrationStatus(policy, url, originalRequest));
196-
}
197-
});
187+
const res = await policy._nextPolicy.sendRequest(reqOptions);
188+
const obj = res.parsedBody;
189+
if (res.parsedBody && obj.registrationState && obj.registrationState === "Registered") {
190+
return true;
191+
} else {
192+
await delay(policy._retryTimeout * 1000);
193+
return getRegistrationStatus(policy, url, originalRequest);
194+
}
198195
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the MIT license.
33

44
import { HttpOperationResponse } from "../httpOperationResponse";
5-
import * as utils from "../util/utils";
65
import { WebResourceLike } from "../webResource";
76
import {
87
BaseRequestPolicy,
@@ -21,6 +20,7 @@ import {
2120
DEFAULT_CLIENT_MIN_RETRY_INTERVAL,
2221
isNumber
2322
} from "../util/exponentialBackoffStrategy";
23+
import { delay } from "../util/delay";
2424

2525
export function systemErrorRetryPolicy(
2626
retryCount?: number,
@@ -107,7 +107,7 @@ async function retry(
107107
if (shouldRetry(policy.retryCount, shouldPolicyRetry, retryData, operationResponse, err)) {
108108
// If previous operation ended with an error and the policy allows a retry, do that
109109
try {
110-
await utils.delay(retryData.retryInterval);
110+
await delay(retryData.retryInterval);
111111
return policy._nextPolicy.sendRequest(request.clone());
112112
} catch (nestedErr) {
113113
return retry(policy, request, operationResponse, nestedErr, retryData);

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
import { WebResourceLike } from "../webResource";
1111
import { HttpOperationResponse } from "../httpOperationResponse";
1212
import { Constants } from "../util/constants";
13-
import { delay } from "../util/utils";
13+
import { delay } from "../util/delay";
14+
import { AbortError } from "@azure/abort-controller";
1415

1516
type ResponseHandler = (
1617
httpRequest: WebResourceLike,
@@ -26,6 +27,8 @@ export function throttlingRetryPolicy(): RequestPolicyFactory {
2627
};
2728
}
2829

30+
const StandardAbortMessage = "The operation was aborted.";
31+
2932
/**
3033
* To learn more, please refer to
3134
* https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-manager-request-limits,
@@ -67,7 +70,14 @@ export class ThrottlingRetryPolicy extends BaseRequestPolicy {
6770
retryAfterHeader
6871
);
6972
if (delayInMs) {
70-
return delay(delayInMs).then((_: any) => this._nextPolicy.sendRequest(httpRequest));
73+
await delay(delayInMs, undefined, {
74+
abortSignal: httpRequest.abortSignal,
75+
abortErrorMsg: StandardAbortMessage
76+
});
77+
if (httpRequest.abortSignal?.aborted) {
78+
throw new AbortError(StandardAbortMessage);
79+
}
80+
return this._nextPolicy.sendRequest(httpRequest);
7181
}
7282
}
7383

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { isDefined } from "./typeguards";
5+
import { AbortError, AbortSignalLike } from "@azure/abort-controller";
6+
const StandardAbortMessage = "The operation was aborted.";
7+
8+
/**
9+
* A wrapper for setTimeout that resolves a promise after delayInMs milliseconds.
10+
* @param delayInMs - The number of milliseconds to be delayed.
11+
* @param value - The value to be resolved with after a timeout of t milliseconds.
12+
* @param options - The options for delay - currently abort options
13+
* @param abortSignal - The abortSignal associated with containing operation.
14+
* @param abortErrorMsg - The abort error message associated with containing operation.
15+
* @returns - Resolved promise
16+
*/
17+
export function delay<T>(
18+
delayInMs: number,
19+
value?: T,
20+
options?: {
21+
abortSignal?: AbortSignalLike;
22+
abortErrorMsg?: string;
23+
}
24+
): Promise<T | void> {
25+
return new Promise((resolve, reject) => {
26+
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
27+
let onAborted: (() => void) | undefined = undefined;
28+
29+
const rejectOnAbort = (): void => {
30+
return reject(
31+
new AbortError(options?.abortErrorMsg ? options?.abortErrorMsg : StandardAbortMessage)
32+
);
33+
};
34+
35+
const removeListeners = (): void => {
36+
if (options?.abortSignal && onAborted) {
37+
options.abortSignal.removeEventListener("abort", onAborted);
38+
}
39+
};
40+
41+
onAborted = (): void => {
42+
if (isDefined(timer)) {
43+
clearTimeout(timer);
44+
}
45+
removeListeners();
46+
return rejectOnAbort();
47+
};
48+
49+
if (options?.abortSignal && options.abortSignal.aborted) {
50+
return rejectOnAbort();
51+
}
52+
53+
timer = setTimeout(() => {
54+
removeListeners();
55+
resolve(value);
56+
}, delayInMs);
57+
58+
if (options?.abortSignal) {
59+
options.abortSignal.addEventListener("abort", onAborted);
60+
}
61+
});
62+
}

0 commit comments

Comments
 (0)