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: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure/app-configuration-provider",
"version": "2.0.1",
"version": "2.0.2",
"description": "The JavaScript configuration provider for Azure App Configuration",
"main": "dist/index.js",
"module": "./dist-esm/index.js",
Expand Down
10 changes: 9 additions & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import dts from "rollup-plugin-dts";

export default [
{
external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto", "dns/promises", "@microsoft/feature-management"],
external: [
"@azure/app-configuration",
"@azure/keyvault-secrets",
"@azure/core-rest-pipeline",
"@azure/identity",
"crypto",
"dns/promises",
"@microsoft/feature-management"
],
input: "src/index.ts",
output: [
{
Expand Down
3 changes: 3 additions & 0 deletions src/AzureAppConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

import { Disposable } from "./common/disposable.js";

/**
* Azure App Configuration provider.
*/
export type AzureAppConfiguration = {
/**
* API to trigger refresh operation.
Expand Down
189 changes: 147 additions & 42 deletions src/AzureAppConfigurationImpl.ts

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions src/AzureAppConfigurationOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@

import { AppConfigurationClientOptions } from "@azure/app-configuration";
import { KeyVaultOptions } from "./keyvault/KeyVaultOptions.js";
import { RefreshOptions } from "./RefreshOptions.js";
import { RefreshOptions } from "./refresh/refreshOptions.js";
import { SettingSelector } from "./types.js";
import { FeatureFlagOptions } from "./featureManagement/FeatureFlagOptions.js";

export const MaxRetries = 2;
export const MaxRetryDelayInMs = 60000;
import { StartupOptions } from "./StartupOptions.js";

export interface AzureAppConfigurationOptions {
/**
Expand Down Expand Up @@ -48,6 +46,11 @@ export interface AzureAppConfigurationOptions {
*/
featureFlagOptions?: FeatureFlagOptions;

/**
* Specifies options used to configure provider startup.
*/
startupOptions?: StartupOptions;

/**
* Specifies whether to enable replica discovery or not.
*
Expand Down
44 changes: 16 additions & 28 deletions src/ConfigurationClientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration";
import { ConfigurationClientWrapper } from "./ConfigurationClientWrapper.js";
import { TokenCredential } from "@azure/identity";
import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions.js";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
import { isBrowser, isWebWorker } from "./requestTracing/utils.js";
import * as RequestTracing from "./requestTracing/constants.js";
import { shuffleList } from "./common/utils.js";
import { shuffleList, instanceOfTokenCredential } from "./common/utils.js";
import { ArgumentError } from "./common/error.js";

// Configuration client retry options
const CLIENT_MAX_RETRIES = 2;
const CLIENT_MAX_RETRY_DELAY = 60_000; // 1 minute in milliseconds

const TCP_ORIGIN_KEY_NAME = "_origin._tcp";
const ALT_KEY_NAME = "_alt";
Expand Down Expand Up @@ -54,18 +59,18 @@ export class ConfigurationClientManager {
const regexMatch = connectionString.match(ConnectionStringRegex);
if (regexMatch) {
const endpointFromConnectionStr = regexMatch[1];
this.endpoint = getValidUrl(endpointFromConnectionStr);
this.endpoint = new URL(endpointFromConnectionStr);
this.#id = regexMatch[2];
this.#secret = regexMatch[3];
} else {
throw new Error(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`);
throw new ArgumentError(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`);
}
staticClient = new AppConfigurationClient(connectionString, this.#clientOptions);
} else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && credentialPassed) {
let endpoint = connectionStringOrEndpoint;
// ensure string is a valid URL.
if (typeof endpoint === "string") {
endpoint = getValidUrl(endpoint);
endpoint = new URL(endpoint);
}

const credential = credentialOrOptions as TokenCredential;
Expand All @@ -75,7 +80,7 @@ export class ConfigurationClientManager {
this.#credential = credential;
staticClient = new AppConfigurationClient(this.endpoint.origin, this.#credential, this.#clientOptions);
} else {
throw new Error("A connection string or an endpoint with credential must be specified to create a client.");
throw new ArgumentError("A connection string or an endpoint with credential must be specified to create a client.");
}

this.#staticClients = [new ConfigurationClientWrapper(this.endpoint.origin, staticClient)];
Expand Down Expand Up @@ -200,12 +205,12 @@ export class ConfigurationClientManager {
});
index++;
}
} catch (err) {
if (err.code === "ENOTFOUND") {
} catch (error) {
if (error.code === "ENOTFOUND") {
// No more SRV records found, return results.
return results;
} else {
throw new Error(`Failed to lookup SRV records: ${err.message}`);
throw new Error(`Failed to lookup SRV records: ${error.message}`);
}
}

Expand Down Expand Up @@ -260,8 +265,8 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat

// retry options
const defaultRetryOptions = {
maxRetries: MaxRetries,
maxRetryDelayInMs: MaxRetryDelayInMs,
maxRetries: CLIENT_MAX_RETRIES,
maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY,
};
const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions);

Expand All @@ -272,20 +277,3 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat
}
});
}

function getValidUrl(endpoint: string): URL {
try {
return new URL(endpoint);
} catch (error) {
if (error.code === "ERR_INVALID_URL") {
throw new Error("Invalid endpoint URL.", { cause: error });
} else {
throw error;
}
}
}

export function instanceOfTokenCredential(obj: unknown) {
return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function";
}

26 changes: 2 additions & 24 deletions src/ConfigurationClientWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
// Licensed under the MIT license.

import { AppConfigurationClient } from "@azure/app-configuration";

const MaxBackoffDuration = 10 * 60 * 1000; // 10 minutes in milliseconds
const MinBackoffDuration = 30 * 1000; // 30 seconds in milliseconds
const MAX_SAFE_EXPONENTIAL = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1.
const JITTER_RATIO = 0.25;
import { getExponentialBackoffDuration } from "./common/backoffUtils.js";

export class ConfigurationClientWrapper {
endpoint: string;
Expand All @@ -25,25 +21,7 @@ export class ConfigurationClientWrapper {
this.backoffEndTime = Date.now();
} else {
this.#failedAttempts += 1;
this.backoffEndTime = Date.now() + calculateBackoffDuration(this.#failedAttempts);
this.backoffEndTime = Date.now() + getExponentialBackoffDuration(this.#failedAttempts);
}
}
}

export function calculateBackoffDuration(failedAttempts: number) {
if (failedAttempts <= 1) {
return MinBackoffDuration;
}

// exponential: minBackoff * 2 ^ (failedAttempts - 1)
const exponential = Math.min(failedAttempts - 1, MAX_SAFE_EXPONENTIAL);
let calculatedBackoffDuration = MinBackoffDuration * (1 << exponential);
if (calculatedBackoffDuration > MaxBackoffDuration) {
calculatedBackoffDuration = MaxBackoffDuration;
}

// jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs
const jitter = JITTER_RATIO * (Math.random() * 2 - 1);

return calculatedBackoffDuration * (1 + jitter);
}
25 changes: 3 additions & 22 deletions src/JsonKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

import { ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration";
import { parseContentType, isJsonContentType } from "./common/contentType.js";
import { IKeyValueAdapter } from "./IKeyValueAdapter.js";

export class JsonKeyValueAdapter implements IKeyValueAdapter {
Expand All @@ -17,7 +18,8 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter {
if (JsonKeyValueAdapter.#ExcludedJsonContentTypes.includes(setting.contentType)) {
return false;
}
return isJsonContentType(setting.contentType);
const contentType = parseContentType(setting.contentType);
return isJsonContentType(contentType);
}

async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> {
Expand All @@ -34,24 +36,3 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter {
return [setting.key, parsedValue];
}
}

// Determine whether a content type string is a valid JSON content type.
// https://docs.microsoft.com/en-us/azure/azure-app-configuration/howto-leverage-json-content-type
function isJsonContentType(contentTypeValue: string): boolean {
if (!contentTypeValue) {
return false;
}

const contentTypeNormalized: string = contentTypeValue.trim().toLowerCase();
const mimeType: string = contentTypeNormalized.split(";", 1)[0].trim();
const typeParts: string[] = mimeType.split("/");
if (typeParts.length !== 2) {
return false;
}

if (typeParts[0] !== "application") {
return false;
}

return typeParts[1].split("+").includes("json");
}
14 changes: 14 additions & 0 deletions src/StartupOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds

export interface StartupOptions {
/**
* The amount of time allowed to load data from Azure App Configuration on startup.
*
* @remarks
* If not specified, the default value is 100 seconds.
*/
timeoutInMs?: number;
}
37 changes: 37 additions & 0 deletions src/common/backoffUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

const MIN_BACKOFF_DURATION = 30_000; // 30 seconds in milliseconds
const MAX_BACKOFF_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds
const JITTER_RATIO = 0.25;

export function getFixedBackoffDuration(timeElapsedInMs: number): number | undefined {
if (timeElapsedInMs < 100_000) {
return 5_000;
}
if (timeElapsedInMs < 200_000) {
return 10_000;
}
if (timeElapsedInMs < 10 * 60 * 1000) {
return MIN_BACKOFF_DURATION;
}
return undefined;
}

export function getExponentialBackoffDuration(failedAttempts: number): number {
if (failedAttempts <= 1) {
return MIN_BACKOFF_DURATION;
}

// exponential: minBackoff * 2 ^ (failedAttempts - 1)
// The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits.
let calculatedBackoffDuration = MIN_BACKOFF_DURATION * Math.pow(2, failedAttempts - 1);
if (calculatedBackoffDuration > MAX_BACKOFF_DURATION) {
calculatedBackoffDuration = MAX_BACKOFF_DURATION;
}

// jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs
const jitter = JITTER_RATIO * (Math.random() * 2 - 1);

return calculatedBackoffDuration * (1 + jitter);
}
62 changes: 62 additions & 0 deletions src/common/contentType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { secretReferenceContentType, featureFlagContentType } from "@azure/app-configuration";

export type ContentType = {
mediaType: string;
parameters: Record<string, string>;
}

export function parseContentType(contentTypeValue: string | undefined): ContentType | undefined {
if (!contentTypeValue) {
return undefined;
}
const [mediaType, ...args] = contentTypeValue.split(";").map((s) => s.trim().toLowerCase());
const parameters: Record<string, string> = {};

for (const param of args) {
const [key, value] = param.split("=").map((s) => s.trim().toLowerCase());
if (key && value) {
parameters[key] = value;
}
}

return { mediaType, parameters };
}

// Determine whether a content type string is a valid JSON content type.
// https://docs.microsoft.com/en-us/azure/azure-app-configuration/howto-leverage-json-content-type
export function isJsonContentType(contentType: ContentType | undefined): boolean {
const mediaType = contentType?.mediaType;
if (!mediaType) {
return false;
}

const typeParts: string[] = mediaType.split("/");
if (typeParts.length !== 2) {
return false;
}

if (typeParts[0] !== "application") {
return false;
}

return typeParts[1].split("+").includes("json");
}

export function isFeatureFlagContentType(contentType: ContentType | undefined): boolean {
const mediaType = contentType?.mediaType;
if (!mediaType) {
return false;
}
return mediaType === featureFlagContentType;
}

export function isSecretReferenceContentType(contentType: ContentType | undefined): boolean {
const mediaType = contentType?.mediaType;
if (!mediaType) {
return false;
}
return mediaType === secretReferenceContentType;
}
Loading