diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index 5d9eaf3..99cfbf3 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -207,9 +207,18 @@ export class ApimClient implements IApimClient { return response; } catch (error) { - // Do not retry client errors (4xx) — they are deterministic, not transient. - // 429 rate-limiting is already handled above and never reaches here. - if (error instanceof HttpError && error.status >= 400 && error.status < 500) { + // APIM reports operations blocked by an API's in-progress async operation + // as a transient 409. Other client errors are deterministic. + const isPessimisticConcurrencyConflict = + error instanceof HttpError && + error.status === 409 && + error.code === 'PessimisticConcurrencyConflict'; + if ( + error instanceof HttpError && + error.status >= 400 && + error.status < 500 && + !isPessimisticConcurrencyConflict + ) { throw error; } if (attempt >= ApimClient.MAX_RETRIES) { diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 9864fac..5590b3f 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -887,6 +887,72 @@ describe('ApimClient HTTP 429 rate limiting', () => { }); }); +describe('ApimClient HTTP 409 conflict handling', () => { + let client: ApimClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new ApimClient(); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + /* eslint-disable @typescript-eslint/no-explicit-any */ + vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token'); + vi.spyOn(client as any, 'delay').mockResolvedValue(undefined); + /* eslint-enable @typescript-eslint/no-explicit-any */ + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('should retry PessimisticConcurrencyConflict responses on PUT operations', async () => { + fetchSpy + .mockResolvedValueOnce( + makeResponse(409, { + error: { + code: 'PessimisticConcurrencyConflict', + message: 'Operation on the API is in progress', + }, + }) + ) + .mockResolvedValueOnce(makeResponse(200, { name: 'my-api' })); + + const descriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + }; + + await expect(client.putResource(testContext, descriptor, {})) + .resolves.toEqual({ name: 'my-api' }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should not retry unrelated HTTP 409 responses', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse(409, { + error: { + code: 'AnotherConflict', + message: 'A non-transient conflict', + }, + }) + ); + + const descriptor = { + type: ResourceType.Api, + nameParts: ['my-api'], + }; + + await expect(client.putResource(testContext, descriptor, {})) + .rejects.toMatchObject({ + status: 409, + code: 'AnotherConflict', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + describe('ApimClient.getApiSpecification', () => { let client: ApimClient; let fetchSpy: ReturnType;