@@ -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