Skip to content

Commit d6f89a9

Browse files
Peter HaugeCopilot
andcommitted
feat: add async LRO polling and OpenAPI spec sanitization
- Implement ARM async long-running operation (LRO) polling in ApimClient for large API spec imports that exceed the 60s provisioning state timeout. Polls Azure-AsyncOperation/Location headers with a 7.5-minute deadline, honors Retry-After, and validates URLs against known ARM hosts. - Add OpenAPI spec sanitizer that auto-injects missing path parameter declarations before APIM import, with warnings logged for each fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 31b9bef commit d6f89a9

2 files changed

Lines changed: 313 additions & 182 deletions

File tree

src/clients/apim-client.ts

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ export class ApimClient implements IApimClient {
4242
private static readonly MAX_DELAY_MS = 30000;
4343
private static readonly MAX_POLLING_ATTEMPTS = 30;
4444
private static readonly POLL_INTERVAL_MS = 2000;
45+
/** Deadline for async (LRO) operation polling — 10 minutes. */
46+
private static readonly ASYNC_POLL_TIMEOUT_MS = 10 * 60 * 1000;
47+
/** Default interval between async operation polls when no Retry-After header. */
48+
private static readonly ASYNC_POLL_INTERVAL_MS = 5000;
49+
/** Known ARM management plane host suffixes for URL validation. */
50+
private static readonly ARM_HOSTS = [
51+
'management.azure.com',
52+
'management.chinacloudapi.cn',
53+
'management.usgovcloudapi.net',
54+
'management.microsoftazure.de',
55+
];
4556
/** Stable ARM API version for Resource Group existence checks */
4657
private static readonly RESOURCE_GROUP_API_VERSION = '2021-04-01';
4758

@@ -353,6 +364,15 @@ export class ApimClient implements IApimClient {
353364
logger.debug(`Skipping provisioning poll for association resource: ${buildResourceLabel(descriptor)}`);
354365
return {};
355366
}
367+
368+
// Prefer ARM async operation polling when the service provides an
369+
// Azure-AsyncOperation or Location header — these long-running operations
370+
// (e.g. large API spec imports) may take minutes to complete.
371+
const asyncUrl = this.extractAsyncOperationUrl(response);
372+
if (asyncUrl) {
373+
return await this.pollAsyncOperation(asyncUrl, context, descriptor);
374+
}
375+
356376
return await this.pollProvisioningState(context, descriptor);
357377
}
358378

@@ -414,9 +434,14 @@ export class ApimClient implements IApimClient {
414434

415435
// Poll for long-running operations
416436
if (response.status === 202) {
417-
await this.pollProvisioningState(context, descriptor, {
418-
treatMissingAsSuccess: true,
419-
});
437+
const asyncUrl = this.extractAsyncOperationUrl(response);
438+
if (asyncUrl) {
439+
await this.pollAsyncOperation(asyncUrl, context, descriptor, { treatMissingAsSuccess: true });
440+
} else {
441+
await this.pollProvisioningState(context, descriptor, {
442+
treatMissingAsSuccess: true,
443+
});
444+
}
420445
}
421446

422447
return true;
@@ -719,6 +744,132 @@ export class ApimClient implements IApimClient {
719744
return 'yaml';
720745
}
721746

747+
/**
748+
* Extract the async operation URL from ARM LRO response headers.
749+
* Prefers Azure-AsyncOperation over Location.
750+
* Validates the URL points to a known ARM management endpoint to prevent
751+
* leaking the bearer token to an unexpected host.
752+
*/
753+
private extractAsyncOperationUrl(response: Response): string | undefined {
754+
const asyncOpUrl = response.headers.get('Azure-AsyncOperation')
755+
?? response.headers.get('Operation-Location')
756+
?? response.headers.get('Location');
757+
758+
if (!asyncOpUrl) return undefined;
759+
760+
// Validate URL host is a known ARM management endpoint
761+
try {
762+
const parsed = new URL(asyncOpUrl);
763+
const isArmHost = ApimClient.ARM_HOSTS.some(
764+
(host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`)
765+
);
766+
if (!isArmHost) {
767+
logger.warn(
768+
`Ignoring async operation URL with unexpected host: ${parsed.hostname}`
769+
);
770+
return undefined;
771+
}
772+
} catch {
773+
logger.warn(`Ignoring malformed async operation URL: ${asyncOpUrl}`);
774+
return undefined;
775+
}
776+
777+
return asyncOpUrl;
778+
}
779+
780+
/**
781+
* Poll an ARM async operation URL until terminal state.
782+
* Used for long-running operations like large API spec imports.
783+
*
784+
* The operation status endpoint returns:
785+
* { "status": "InProgress" | "Succeeded" | "Failed" | "Canceled", ... }
786+
*
787+
* On success, GETs the original resource to return the final state.
788+
*/
789+
private async pollAsyncOperation(
790+
operationUrl: string,
791+
context: ApimServiceContext,
792+
descriptor: ResourceDescriptor,
793+
options: { treatMissingAsSuccess?: boolean } = {}
794+
): Promise<Record<string, unknown>> {
795+
const { treatMissingAsSuccess = false } = options;
796+
const label = buildResourceLabel(descriptor);
797+
const deadline = Date.now() + ApimClient.ASYNC_POLL_TIMEOUT_MS;
798+
let pollInterval = ApimClient.ASYNC_POLL_INTERVAL_MS;
799+
800+
logger.debug(`Polling async operation for ${label}: ${operationUrl}`);
801+
802+
while (Date.now() < deadline) {
803+
await this.delay(pollInterval);
804+
805+
const response = await this.request(operationUrl);
806+
807+
// Honour Retry-After if the service provides it
808+
const retryAfter = response.headers.get('Retry-After');
809+
if (retryAfter) {
810+
const parsed = parseInt(retryAfter, 10);
811+
if (!isNaN(parsed) && parsed > 0) {
812+
pollInterval = parsed * 1000;
813+
}
814+
}
815+
816+
// Some Location-style polls respond with 202 (still in progress)
817+
// and may not have a JSON body — keep polling.
818+
if (response.status === 202) {
819+
logger.debug(`Async operation still in progress (HTTP 202) for ${label}`);
820+
continue;
821+
}
822+
823+
// Try to parse the status body
824+
const text = await response.text();
825+
if (!text.trim()) {
826+
// Empty body on 200/204 — treat as complete
827+
if (response.ok) {
828+
return treatMissingAsSuccess
829+
? {}
830+
: await this.getResource(context, descriptor) ?? {};
831+
}
832+
throw new Error(`Async operation returned empty body with status ${response.status} for ${label}`);
833+
}
834+
835+
let body: Record<string, unknown>;
836+
try {
837+
body = JSON.parse(text) as Record<string, unknown>;
838+
} catch {
839+
// Non-JSON on an OK status — treat as complete
840+
if (response.ok) {
841+
return treatMissingAsSuccess
842+
? {}
843+
: await this.getResource(context, descriptor) ?? {};
844+
}
845+
throw new Error(`Async operation returned non-JSON with status ${response.status} for ${label}`);
846+
}
847+
848+
const status = (body.status as string | undefined)?.toLowerCase();
849+
850+
if (status === 'succeeded') {
851+
logger.debug(`Async operation succeeded for ${label}`);
852+
if (treatMissingAsSuccess) return {};
853+
// GET the final resource state
854+
return await this.getResource(context, descriptor) ?? {};
855+
}
856+
857+
if (status === 'failed' || status === 'canceled' || status === 'cancelled') {
858+
const error = body.error as Record<string, unknown> | undefined;
859+
const code = error?.code ?? 'UnknownError';
860+
const message = error?.message ?? JSON.stringify(body);
861+
throw new Error(`Async operation ${status} for ${label}: [${code}] ${message}`);
862+
}
863+
864+
// InProgress or other intermediate status — keep polling
865+
logger.debug(`Async operation status: ${status ?? 'unknown'} for ${label}`);
866+
}
867+
868+
throw new Error(
869+
`Async operation polling timed out after ${ApimClient.ASYNC_POLL_TIMEOUT_MS / 1000}s for ${label}`
870+
);
871+
}
872+
722873
private async pollProvisioningState(
723874
context: ApimServiceContext,
724875
descriptor: ResourceDescriptor,

0 commit comments

Comments
 (0)