Skip to content

Commit 470a3f3

Browse files
Support load configuration settings from Azure Front Door (#223)
* wip * load from azure front door * fix bug * add more test * add browser test * update * update * fix test * remove sync-token header * wip * update * fix lint * update CDN tag * disallow sentinel key refresh for AFD * update * update * update * update * resolve merge conflict * update error message * update
1 parent 7b97284 commit 470a3f3

16 files changed

+540
-31
lines changed

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"playwright": "^1.55.0"
6767
},
6868
"dependencies": {
69-
"@azure/app-configuration": "^1.9.0",
69+
"@azure/app-configuration": "^1.9.2",
7070
"@azure/core-rest-pipeline": "^1.6.0",
7171
"@azure/identity": "^4.2.1",
7272
"@azure/keyvault-secrets": "^4.7.0",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { PipelinePolicy } from "@azure/core-rest-pipeline";
5+
6+
/**
7+
* The pipeline policy that remove the authorization header from the request to allow anonymous access to the Azure Front Door.
8+
* @remarks
9+
* The policy position should be perRetry, since it should be executed after the "Sign" phase: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/core/core-client/src/serviceClient.ts
10+
*/
11+
export class AnonymousRequestPipelinePolicy implements PipelinePolicy {
12+
name: string = "AppConfigurationAnonymousRequestPolicy";
13+
14+
async sendRequest(request, next) {
15+
if (request.headers.has("authorization")) {
16+
request.headers.delete("authorization");
17+
}
18+
return next(request);
19+
}
20+
}
21+
22+
/**
23+
* The pipeline policy that remove the "sync-token" header from the request.
24+
* The policy position should be perRetry. It should be executed after the SyncTokenPolicy in @azure/app-configuration, which is executed after retry phase: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts#L198
25+
*/
26+
export class RemoveSyncTokenPipelinePolicy implements PipelinePolicy {
27+
name: string = "AppConfigurationRemoveSyncTokenPolicy";
28+
29+
async sendRequest(request, next) {
30+
if (request.headers.has("sync-token")) {
31+
request.headers.delete("sync-token");
32+
}
33+
return next(request);
34+
}
35+
}

src/afd/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export const X_MS_DATE_HEADER = "x-ms-date";

src/appConfigurationImpl.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import {
1414
GetSnapshotOptions,
1515
ListConfigurationSettingsForSnapshotOptions,
1616
GetSnapshotResponse,
17-
KnownSnapshotComposition
17+
KnownSnapshotComposition,
18+
ListConfigurationSettingPage
1819
} from "@azure/app-configuration";
19-
import { isRestError } from "@azure/core-rest-pipeline";
20+
import { isRestError, RestError } from "@azure/core-rest-pipeline";
2021
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./appConfiguration.js";
2122
import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js";
2223
import { IKeyValueAdapter } from "./keyValueAdapter.js";
@@ -67,6 +68,7 @@ import { ConfigurationClientManager } from "./configurationClientManager.js";
6768
import { getFixedBackoffDuration, getExponentialBackoffDuration } from "./common/backoffUtils.js";
6869
import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError } from "./common/errors.js";
6970
import { ErrorMessages } from "./common/errorMessages.js";
71+
import { X_MS_DATE_HEADER } from "./afd/constants.js";
7072

7173
const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds
7274

@@ -128,12 +130,17 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
128130
// Load balancing
129131
#lastSuccessfulEndpoint: string = "";
130132

133+
// Azure Front Door
134+
#isAfdUsed: boolean = false;
135+
131136
constructor(
132137
clientManager: ConfigurationClientManager,
133138
options: AzureAppConfigurationOptions | undefined,
139+
isAfdUsed: boolean
134140
) {
135141
this.#options = options;
136142
this.#clientManager = clientManager;
143+
this.#isAfdUsed = isAfdUsed;
137144

138145
// enable request tracing if not opt-out
139146
this.#requestTracingEnabled = requestTracingEnabled();
@@ -221,7 +228,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
221228
isFailoverRequest: this.#isFailoverRequest,
222229
featureFlagTracing: this.#featureFlagTracing,
223230
fmVersion: this.#fmVersion,
224-
aiConfigurationTracing: this.#aiConfigurationTracing
231+
aiConfigurationTracing: this.#aiConfigurationTracing,
232+
isAfdUsed: this.#isAfdUsed
225233
};
226234
}
227235

@@ -490,7 +498,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
490498
* If false, loads key-value using the key-value selectors. Defaults to false.
491499
*/
492500
async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise<ConfigurationSetting[]> {
493-
const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors;
501+
const selectors: PagedSettingsWatcher[] = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors;
494502

495503
// Use a Map to deduplicate configuration settings by key. When multiple selectors return settings with the same key,
496504
// the configuration setting loaded by the later selector in the iteration order will override the one from the earlier selector.
@@ -509,6 +517,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
509517
tagsFilter: selector.tagFilters
510518
};
511519
const { items, pageWatchers } = await this.#listConfigurationSettings(listOptions);
520+
512521
selector.pageWatchers = pageWatchers;
513522
settings = items;
514523
} else { // snapshot selector
@@ -675,7 +684,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
675684
return Promise.resolve(false);
676685
}
677686

678-
const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors);
687+
let needRefresh = false;
688+
needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors);
689+
679690
if (needRefresh) {
680691
await this.#loadFeatureFlags();
681692
}
@@ -718,21 +729,38 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
718729
const listOptions: ListConfigurationSettingsOptions = {
719730
keyFilter: selector.keyFilter,
720731
labelFilter: selector.labelFilter,
721-
tagsFilter: selector.tagFilters,
722-
pageEtags: pageWatchers.map(w => w.etag ?? "")
732+
tagsFilter: selector.tagFilters
723733
};
724734

735+
if (!this.#isAfdUsed) {
736+
// if AFD is not used, add page etags to the listOptions to send conditional request
737+
listOptions.pageEtags = pageWatchers.map(w => w.etag ?? "") ;
738+
}
739+
725740
const pageIterator = listConfigurationSettingsWithTrace(
726741
this.#requestTraceOptions,
727742
client,
728743
listOptions
729744
).byPage();
730745

746+
let i = 0;
731747
for await (const page of pageIterator) {
732-
// when conditional request is sent, the response will be 304 if not changed
733-
if (page._response.status === 200) { // created or changed
748+
const serverResponseTime: Date = this.#getMsDateHeader(page);
749+
if (i >= pageWatchers.length) {
734750
return true;
735751
}
752+
753+
const lastServerResponseTime = pageWatchers[i].lastServerResponseTime;
754+
let isResponseFresh = false;
755+
if (lastServerResponseTime !== undefined) {
756+
isResponseFresh = serverResponseTime > lastServerResponseTime;
757+
}
758+
if (isResponseFresh &&
759+
page._response.status === 200 && // conditional request returns 304 if not changed
760+
page.etag !== pageWatchers[i].etag) {
761+
return true;
762+
}
763+
i++;
736764
}
737765
}
738766
return false;
@@ -743,7 +771,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
743771
}
744772

745773
/**
746-
* Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error.
774+
* Gets a configuration setting by key and label. If the setting is not found, return undefined instead of throwing an error.
747775
*/
748776
async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions): Promise<GetConfigurationSettingResponse | undefined> {
749777
const funcToExecute = async (client) => {
@@ -779,7 +807,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
779807

780808
const items: ConfigurationSetting[] = [];
781809
for await (const page of pageIterator) {
782-
pageWatchers.push({ etag: page.etag });
810+
pageWatchers.push({ etag: page.etag, lastServerResponseTime: this.#getMsDateHeader(page) });
783811
items.push(...page.items);
784812
}
785813
return { items, pageWatchers };
@@ -1107,6 +1135,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
11071135
return first15Bytes.toString("base64url");
11081136
}
11091137
}
1138+
1139+
/**
1140+
* Extracts the response timestamp (x-ms-date) from the response headers. If not found, returns the current time.
1141+
*/
1142+
#getMsDateHeader(response: GetConfigurationSettingResponse | ListConfigurationSettingPage | RestError): Date {
1143+
let header: string | undefined;
1144+
if (isRestError(response)) {
1145+
header = response.response?.headers?.get(X_MS_DATE_HEADER);
1146+
} else {
1147+
header = response._response?.headers?.get(X_MS_DATE_HEADER);
1148+
}
1149+
if (header !== undefined) {
1150+
const date = new Date(header);
1151+
if (!isNaN(date.getTime())) {
1152+
return date;
1153+
}
1154+
}
1155+
return new Date();
1156+
}
11101157
}
11111158

11121159
function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] {

src/common/errorMessages.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export const enum ErrorMessages {
2020
INVALID_LABEL_FILTER = "The characters '*' and ',' are not supported in label filters.",
2121
INVALID_TAG_FILTER = "Tag filter must follow the format 'tagName=tagValue'",
2222
CONNECTION_STRING_OR_ENDPOINT_MISSED = "A connection string or an endpoint with credential must be specified to create a client.",
23+
REPLICA_DISCOVERY_NOT_SUPPORTED = "Replica discovery is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd",
24+
LOAD_BALANCING_NOT_SUPPORTED = "Load balancing is not supported when loading from Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd",
25+
WATCHED_SETTINGS_NOT_SUPPORTED = "Specifying watched settings is not supported when loading from Azure Front Door. If refresh is enabled, all loaded configuration settings will be watched automatically."
2326
}
2427

2528
export const enum KeyVaultReferenceErrorMessages {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

44
export { AzureAppConfiguration } from "./appConfiguration.js";
55
export { Disposable } from "./common/disposable.js";
6-
export { load } from "./load.js";
6+
export { load, loadFromAzureFrontDoor } from "./load.js";
77
export { KeyFilter, LabelFilter } from "./types.js";
88
export { VERSION } from "./version.js";

src/load.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ import { AzureAppConfiguration } from "./appConfiguration.js";
66
import { AzureAppConfigurationImpl } from "./appConfigurationImpl.js";
77
import { AzureAppConfigurationOptions } from "./appConfigurationOptions.js";
88
import { ConfigurationClientManager } from "./configurationClientManager.js";
9+
import { AnonymousRequestPipelinePolicy, RemoveSyncTokenPipelinePolicy } from "./afd/afdRequestPipelinePolicy.js";
910
import { instanceOfTokenCredential } from "./common/utils.js";
11+
import { ArgumentError } from "./common/errors.js";
12+
import { ErrorMessages } from "./common/errorMessages.js";
1013

1114
const MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS: number = 5_000;
1215

16+
// Empty token credential to be used when loading from Azure Front Door
17+
const emptyTokenCredential: TokenCredential = {
18+
getToken: async () => ({ token: "", expiresOnTimestamp: Number.MAX_SAFE_INTEGER })
19+
};
20+
1321
/**
1422
* Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration.
1523
* @param connectionString The connection string for the App Configuration store.
@@ -19,7 +27,7 @@ export async function load(connectionString: string, options?: AzureAppConfigura
1927

2028
/**
2129
* Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration.
22-
* @param endpoint The URL to the App Configuration store.
30+
* @param endpoint The App Configuration store endpoint.
2331
* @param credential The credential to use to connect to the App Configuration store.
2432
* @param options Optional parameters.
2533
*/
@@ -42,7 +50,8 @@ export async function load(
4250
}
4351

4452
try {
45-
const appConfiguration = new AzureAppConfigurationImpl(clientManager, options);
53+
const isAfdUsed: boolean = credentialOrOptions === emptyTokenCredential;
54+
const appConfiguration = new AzureAppConfigurationImpl(clientManager, options, isAfdUsed);
4655
await appConfiguration.load();
4756
return appConfiguration;
4857
} catch (error) {
@@ -56,3 +65,38 @@ export async function load(
5665
throw error;
5766
}
5867
}
68+
69+
/**
70+
* Loads the data from Azure Front Door and returns an instance of AzureAppConfiguration.
71+
* @param endpoint The Azure Front Door endpoint.
72+
* @param appConfigOptions Optional parameters.
73+
*/
74+
export async function loadFromAzureFrontDoor(endpoint: URL | string, options?: AzureAppConfigurationOptions): Promise<AzureAppConfiguration>;
75+
76+
export async function loadFromAzureFrontDoor(
77+
endpoint: string | URL,
78+
appConfigOptions: AzureAppConfigurationOptions = {}
79+
): Promise<AzureAppConfiguration> {
80+
if (appConfigOptions.replicaDiscoveryEnabled) {
81+
throw new ArgumentError(ErrorMessages.REPLICA_DISCOVERY_NOT_SUPPORTED);
82+
}
83+
if (appConfigOptions.loadBalancingEnabled) {
84+
throw new ArgumentError(ErrorMessages.LOAD_BALANCING_NOT_SUPPORTED);
85+
}
86+
if (appConfigOptions.refreshOptions?.watchedSettings && appConfigOptions.refreshOptions.watchedSettings.length > 0) {
87+
throw new ArgumentError(ErrorMessages.WATCHED_SETTINGS_NOT_SUPPORTED);
88+
}
89+
90+
appConfigOptions.replicaDiscoveryEnabled = false; // Disable replica discovery when loading from Azure Front Door
91+
92+
appConfigOptions.clientOptions = {
93+
...appConfigOptions.clientOptions,
94+
additionalPolicies: [
95+
...(appConfigOptions.clientOptions?.additionalPolicies || []),
96+
{ policy: new AnonymousRequestPipelinePolicy(), position: "perRetry" },
97+
{ policy: new RemoveSyncTokenPipelinePolicy(), position: "perRetry" }
98+
]
99+
};
100+
101+
return await load(endpoint, emptyTokenCredential, appConfigOptions);
102+
}

src/requestTracing/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount";
5151
export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault";
5252
export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault";
5353
export const FAILOVER_REQUEST_TAG = "Failover";
54+
export const AFD_USED_TAG = "AFD";
5455

5556
// Compact feature tags
5657
export const FEATURES_KEY = "Features";

0 commit comments

Comments
 (0)