Skip to content
Merged
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
4 changes: 4 additions & 0 deletions sdk/appconfiguration/app-configuration/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

### Bugs Fixed

- Throttling may have resulted in retrying the request indefinitely if the service responded with `retry-after-ms` header in the error for each retried request. The behaviour has been changed to retry for a maximum of 3 times by default from [#16376](https://github.com/Azure/azure-sdk-for-js/pull/16376).
- Additionally, [#16376](https://github.com/Azure/azure-sdk-for-js/pull/16376) also exposes retryOptions on the `AppConfigurationClient`'s client options, which lets you configure the `maxRetries` and the `maxRetryDelayInMs`.
- More resources - [App Configuration | Throttling](https://docs.microsoft.com/azure/azure-app-configuration/rest-api-throttling) and [App Configuration | Requests Quota](https://docs.microsoft.com/azure/azure-app-configuration/faq#which-app-configuration-tier-should-i-use)

### Other Changes

## 1.2.0 (2021-07-07)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class AppConfigurationClient {

// @public
export interface AppConfigurationClientOptions {
retryOptions?: RetryOptions;
userAgentOptions?: UserAgentOptions;
}

Expand Down Expand Up @@ -174,6 +175,12 @@ export function parseFeatureFlag(setting: ConfigurationSetting): ConfigurationSe
// @public
export function parseSecretReference(setting: ConfigurationSetting): ConfigurationSetting<SecretReferenceValue>;

// @public
export interface RetryOptions {
maxRetries?: number;
maxRetryDelayInMs?: number;
}

// @public
export const secretReferenceContentType = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
ListConfigurationSettingsOptions,
ListRevisionsOptions,
ListRevisionsPage,
RetryOptions,
SetConfigurationSettingOptions,
SetConfigurationSettingParam,
SetConfigurationSettingResponse,
Expand Down Expand Up @@ -99,6 +100,11 @@ export interface AppConfigurationClientOptions {
* Options for adding user agent details to outgoing requests.
*/
userAgentOptions?: UserAgentOptions;

/**
* Options that control how to retry failed requests.
*/
retryOptions?: RetryOptions;
}

/**
Expand Down Expand Up @@ -529,7 +535,7 @@ export function getGeneratedClientOptions(
const retryPolicies = [
exponentialRetryPolicy(),
systemErrorRetryPolicy(),
throttlingRetryPolicy()
throttlingRetryPolicy(internalAppConfigOptions.retryOptions)
];

const userAgent = getUserAgentPrefix(
Expand Down
15 changes: 15 additions & 0 deletions sdk/appconfiguration/app-configuration/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,18 @@ export interface SetReadOnlyResponse
extends ConfigurationSetting,
SyncTokenHeaderField,
HttpResponseField<SyncTokenHeaderField> {}

/**
* Options that control how to retry failed requests.
*/
export interface RetryOptions {
/**
* The maximum number of retry attempts. Defaults to 3.
*/
maxRetries?: number;

/**
* The maximum delay in milliseconds allowed before retrying an operation.
*/
maxRetryDelayInMs?: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,24 @@ import {
RestError
} from "@azure/core-http";
import { delay } from "@azure/core-http";
import { RetryOptions } from "../models";

/**
* @internal
*/
export function throttlingRetryPolicy(): RequestPolicyFactory {
export function throttlingRetryPolicy(retryOptions?: RetryOptions): RequestPolicyFactory {
return {
create: (nextPolicy: RequestPolicy, options: RequestPolicyOptions) => {
return new ThrottlingRetryPolicy(nextPolicy, options);
return new ThrottlingRetryPolicy(nextPolicy, options, retryOptions);
}
};
}

const StandardAbortMessage = "The operation was aborted.";

// Merge this constant with the one in core-http when we unify throttling retry policy in core-http and app-config
const DEFAULT_CLIENT_RETRY_COUNT = 3;

/**
* This policy is a close copy of the ThrottlingRetryPolicy class from
* core-http with modifications to work with how AppConfig is currently
Expand All @@ -35,27 +39,51 @@ const StandardAbortMessage = "The operation was aborted.";
* @internal
*/
export class ThrottlingRetryPolicy extends BaseRequestPolicy {
constructor(nextPolicy: RequestPolicy, options: RequestPolicyOptions) {
private numberOfRetries = 0;
constructor(
nextPolicy: RequestPolicy,
options: RequestPolicyOptions,
private retryOptions: RetryOptions = { maxRetries: DEFAULT_CLIENT_RETRY_COUNT }
) {
super(nextPolicy, options);
}

public async sendRequest(httpRequest: WebResource): Promise<HttpOperationResponse> {
return this._nextPolicy.sendRequest(httpRequest.clone()).catch(async (err) => {
if (isRestErrorWithHeaders(err)) {
const delayInMs = getDelayInMs(err.response.headers);
let delayInMs = getDelayInMs(err.response.headers);

if (delayInMs == null) {
throw err;
}

if (
this.retryOptions.maxRetryDelayInMs &&
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we set this to the default in the constructor default option value? Otherwise the delay can go unbounded.

    private retryOptions: Pick<RetryOptions, "maxRetries" | "maxRetryDelayInMs"> = {
      maxRetryDelayInMs: 90*1000 // is this constant defined somewhere?
      maxRetries: DEFAULT_CLIENT_RETRY_COUNT
    }

Copy link
Copy Markdown
Contributor Author

@HarshaNalluru HarshaNalluru Jul 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for maxRetries, will add that.
I don't want to define maxRetryDelayInMs since different services may have different values.

For example, with the observations from app-config, I have seen values anywhere between 20ms and 300000ms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the throttlign policy case, if the error from the service is with the delay, then we use it for delay and throw directly otherwise. I don't think there is a case of unboundedness.

maxRetryDelayInMs should only be provided by the user according to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds fair. Please update the ref doc for maxRetryDelayInMs to remove the mentioning of default value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

delayInMs > this.retryOptions.maxRetryDelayInMs
) {
delayInMs = this.retryOptions.maxRetryDelayInMs;
}

this.numberOfRetries += 1;
await delay(delayInMs, undefined, {
abortSignal: httpRequest.abortSignal,
abortErrorMsg: StandardAbortMessage
});
if (httpRequest.abortSignal?.aborted) {
throw new AbortError(StandardAbortMessage);
}
return await this.sendRequest(httpRequest.clone());

if (this.retryOptions.maxRetries == undefined) {
this.retryOptions.maxRetries = DEFAULT_CLIENT_RETRY_COUNT;
}

if (this.numberOfRetries < this.retryOptions.maxRetries) {
// retries
return this.sendRequest(httpRequest.clone());
} else {
// passes on to the next policy
return this._nextPolicy.sendRequest(httpRequest.clone());
}
} else {
throw err;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import { AbortController } from "@azure/abort-controller";
import nock from "nock";
import { generateUuid } from "@azure/core-http";

describe("Should not retry forever - honors the abort signal passed", () => {
describe("Should not retry forever", () => {
let client: AppConfigurationClient;
const connectionString = "Endpoint=https://myappconfig.azconfig.io;Id=key:ai/u/fake;Secret=abcd=";

beforeEach(function() {
function mockErrorResponse(retryAfterMs: string, persistence: boolean = true) {
if (!nock.isActive()) {
nock.activate();
}
nock("https://myappconfig.azconfig.io:443")
.persist()
.persist(persistence)
.put(/.*/g)
.reply(
429,
Expand All @@ -26,9 +26,11 @@ describe("Should not retry forever - honors the abort signal passed", () => {
policy: "Total Requests",
status: 429
},
["retry-after-ms", "123456"]
["retry-after-ms", retryAfterMs]
);
}

beforeEach(() => {
client = new AppConfigurationClient(connectionString);
});

Expand All @@ -38,7 +40,8 @@ describe("Should not retry forever - honors the abort signal passed", () => {
nock.enableNetConnect();
});

it("simulate the service throttling", async () => {
it("simulate the service throttling - honors the abort signal passed", async () => {
mockErrorResponse("123456");
const key = generateUuid();
const numberOfSettings = 200;
const promises = [];
Expand All @@ -64,4 +67,40 @@ describe("Should not retry forever - honors the abort signal passed", () => {
}
chai.assert.equal(errorWasThrown, true, "Error was not thrown");
});

it("should not retry forever without abortSignal", async () => {
const responseCount = 10;
for (let index = 0; index < responseCount; index++) {
mockErrorResponse("100", false);
}
const key = generateUuid();
let errorWasThrown = false;

chai.assert.equal(
nock.pendingMocks().length,
responseCount,
"unexpected pending mocks before making the request"
);
try {
await client.addConfigurationSetting({
key: key,
value: "added"
});
} catch (error) {
errorWasThrown = true;
chai.assert.equal(error.name, "RestError", "Unexpected error thrown");
chai.assert.equal(JSON.parse(error.message).status, 429, "Unexpected error thrown");
chai.assert.equal(
JSON.parse(error.message).title,
"Resource utilization has surpassed the assigned quota",
"Unexpected error thrown"
);
}
chai.assert.equal(errorWasThrown, true, "Error was not thrown");
chai.assert.equal(
nock.pendingMocks().length,
responseCount - 1 - 3, // one attempt + three retries
"unexpected pending mocks after the test was run"
);
});
});