From 4188b48fa757968aeb299a4c1cab3b4cac43a2d9 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 6 May 2026 11:39:18 +0530 Subject: [PATCH 1/5] Add retry logic --- .../lib/actions/custom-azure-api-action.ts | 240 ++++++++++++++++-- .../test/custom-azure-api-action.test.ts | 187 ++++++++++++++ .../common/src/lib/http/core/http-error.ts | 5 + 3 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 packages/blocks/azure/test/custom-azure-api-action.test.ts diff --git a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts index c797669dc2..87298df55f 100644 --- a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts +++ b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts @@ -1,16 +1,34 @@ -import { createCustomApiCallAction } from '@openops/blocks-common'; -import { Property } from '@openops/blocks-framework'; +import { + HttpError, + HttpHeaders, + HttpMethod, + HttpRequest, + QueryParams, + httpClient, +} from '@openops/blocks-common'; +import { Property, createAction } from '@openops/blocks-framework'; import { azureAuth, getUseHostSessionProperty } from '@openops/common'; import { getAzureAccessToken } from '../auth/get-azure-access-token'; import { getSubscriptionsDropdownForHostSession } from '../common-properties'; -export const customAzureApiCallAction = createCustomApiCallAction({ +const DEFAULT_BASE_URL = 'https://management.azure.com/?api-version=2025-04-01'; +const DEFAULT_RETRY_DELAY_MS = 60000; +const MAX_RETRY_ATTEMPTS = 4; +const COST_MANAGEMENT_RETRY_AFTER_HEADER_PATTERN = + /^x-ms-ratelimit-microsoft\.costmanagement.*-retry-after$/i; +const RETRY_AFTER_HEADERS = [ + 'x-ms-ratelimit-microsoft.consumption-retry-after', + 'x-ms-ratelimit-retailprices-retry-after', + 'retry-after', +]; + +export const customAzureApiCallAction = createAction({ auth: azureAuth, name: 'custom_azure_api_call', description: 'Make a custom REST API call to Azure.', displayName: 'Custom Azure API Call', - baseUrl: () => 'https://management.azure.com/?api-version=2025-04-01', - additionalProps: { + isWriteAction: true, + props: { documentation: Property.MarkDown({ value: 'For more information, visit the [Azure API documentation](https://learn.microsoft.com/rest/api/azure/).', @@ -36,22 +54,204 @@ export const customAzureApiCallAction = createCustomApiCallAction({ return {}; }, }), + url: Property.DynamicProperties({ + displayName: '', + required: true, + refreshers: ['auth'], + props: async () => { + return { + url: Property.ShortText({ + displayName: 'URL', + description: 'The full URL to use, including the base URL', + required: true, + defaultValue: DEFAULT_BASE_URL, + }), + }; + }, + }), + method: Property.StaticDropdown({ + displayName: 'Method', + required: true, + options: { + options: Object.values(HttpMethod).map((value) => ({ + label: value, + value, + })), + }, + }), + headers: Property.Object({ + displayName: 'Headers', + description: + 'Authorization headers are injected automatically from your connection.', + required: false, + }), + queryParams: Property.Object({ + displayName: 'Query Parameters', + required: false, + }), + body: Property.Json({ + displayName: 'Body', + required: false, + }), + failsafe: Property.Checkbox({ + displayName: 'No Error on Failure', + required: false, + }), + timeout: Property.Number({ + displayName: 'Timeout (in seconds)', + required: false, + }), }, - authMapping: async (context: any) => { - const shouldUseHostCredentials = - context.propsValue.useHostSession?.['useHostSessionCheckbox']; - const selectedSubscription = - context.propsValue?.subscriptions?.['subDropdown']; - - const token = await getAzureAccessToken( - context.auth, - !!shouldUseHostCredentials, - selectedSubscription, - ); - - return { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', + run: async (context) => { + const { method, url, headers, queryParams, body, failsafe, timeout } = + context.propsValue; + + const urlValue = url?.['url']; + if (!method || !urlValue) { + throw new Error('Method and URL are required.'); + } + + let headersValue = (headers as HttpHeaders | undefined) ?? {}; + const authHeaders = await getAuthHeaders(context); + headersValue = { + ...headersValue, + ...authHeaders, + }; + + const request: HttpRequest> = { + method, + url: urlValue, + headers: headersValue, + queryParams: (queryParams as QueryParams | undefined) ?? {}, + timeout: timeout ? timeout * 1000 : 0, + ...(body ? { body } : {}), }; + + try { + return await sendWithRetry(request); + } catch (error) { + if (failsafe && error instanceof HttpError) { + return error.errorMessage(); + } + throw error; + } }, }); + +async function getAuthHeaders(context: any): Promise { + const shouldUseHostCredentials = + context.propsValue.useHostSession?.['useHostSessionCheckbox']; + const selectedSubscription = + context.propsValue?.subscriptions?.['subDropdown']; + + const token = await getAzureAccessToken( + context.auth, + !!shouldUseHostCredentials, + selectedSubscription, + ); + + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; +} + +async function sendWithRetry( + request: HttpRequest>, +): Promise { + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + try { + return await httpClient.sendRequest(request); + } catch (error) { + if (!(error instanceof HttpError) || error.response.status !== 429) { + throw error; + } + + if (attempt === MAX_RETRY_ATTEMPTS) { + throw error; + } + + await sleep(getRetryDelayMs(error.response.headers, attempt)); + } + } + + throw new Error(`Failed to send Azure API request to ${request.url}`); +} + +export function getRetryDelayMs( + headers: HttpHeaders | undefined, + attempt: number, +): number { + const retryDelayMs = getHeaderRetryDelayMs(headers); + if (retryDelayMs === null) { + return Math.pow(2, attempt - 1) * DEFAULT_RETRY_DELAY_MS; + } + + return retryDelayMs; +} + +function getHeaderRetryDelayMs( + headers: HttpHeaders | undefined, +): number | null { + if (!headers) { + return null; + } + + const retryValues: string[] = []; + + for (const [headerName, headerValue] of Object.entries(headers)) { + if ( + RETRY_AFTER_HEADERS.includes(headerName.toLowerCase()) || + COST_MANAGEMENT_RETRY_AFTER_HEADER_PATTERN.test(headerName) + ) { + retryValues.push(...normalizeHeaderValues(headerValue)); + } + } + + if (retryValues.length === 0) { + return null; + } + + const retryDelaysMs = retryValues + .map((value) => parseRetryDelayMs(value)) + .filter((value): value is number => value !== null); + + if (retryDelaysMs.length === 0) { + return null; + } + + return Math.max(...retryDelaysMs); +} + +function normalizeHeaderValues( + headerValue: string | string[] | undefined, +): string[] { + if (!headerValue) { + return []; + } + + return Array.isArray(headerValue) ? headerValue : [headerValue]; +} + +function parseRetryDelayMs(headerValue: string): number | null { + const trimmedValue = headerValue.trim(); + if (trimmedValue.length === 0) { + return null; + } + + const retryAfterSeconds = Number.parseInt(trimmedValue, 10); + if (Number.isFinite(retryAfterSeconds)) { + return retryAfterSeconds * 1000; + } + + const retryDate = Date.parse(trimmedValue); + if (Number.isNaN(retryDate)) { + return null; + } + + return Math.max(retryDate - Date.now(), DEFAULT_RETRY_DELAY_MS); +} + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} diff --git a/packages/blocks/azure/test/custom-azure-api-action.test.ts b/packages/blocks/azure/test/custom-azure-api-action.test.ts new file mode 100644 index 0000000000..15b732d619 --- /dev/null +++ b/packages/blocks/azure/test/custom-azure-api-action.test.ts @@ -0,0 +1,187 @@ +const authenticateUserWithAzureMock = jest.fn(); + +const openOpsMock = { + ...jest.requireActual('@openops/common'), + getUseHostSessionProperty: jest.fn().mockReturnValue({ + type: 'DYNAMIC', + required: true, + }), + authenticateUserWithAzure: authenticateUserWithAzureMock, +}; + +jest.mock('@openops/common', () => openOpsMock); + +import { HttpError } from '@openops/blocks-common'; +import axios from 'axios'; +import { + customAzureApiCallAction, + getRetryDelayMs, +} from '../src/lib/actions/custom-azure-api-action'; + +describe('customAzureApiCallAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + authenticateUserWithAzureMock.mockResolvedValue({ + access_token: 'mock-access-token', + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + test('should retry 429 responses using Azure Cost Management retry header', async () => { + jest + .spyOn(axios, 'request') + .mockRejectedValueOnce( + buildAxiosError({ + status: 429, + headers: { + 'x-ms-ratelimit-microsoft.costmanagement-entity-retry-after': '1', + }, + }), + ) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: { ok: true }, + } as any); + + const runPromise = customAzureApiCallAction.run(createContext() as any); + + await jest.runAllTimersAsync(); + + await expect(runPromise).resolves.toEqual({ + status: 200, + headers: {}, + body: { ok: true }, + }); + }); + + test('should retry 429 responses using Azure Retail Prices retry header', async () => { + jest + .spyOn(axios, 'request') + .mockRejectedValueOnce( + buildAxiosError({ + status: 429, + headers: { + 'x-ms-ratelimit-retailprices-retry-after': '60', + }, + }), + ) + .mockResolvedValueOnce({ + status: 200, + headers: {}, + data: { ok: true }, + } as any); + + const runPromise = customAzureApiCallAction.run(createContext() as any); + + await jest.runAllTimersAsync(); + + await expect(runPromise).resolves.toEqual({ + status: 200, + headers: {}, + body: { ok: true }, + }); + }); + + test('should use Azure Cost Management retry header delay', () => { + expect( + getRetryDelayMs( + { + 'x-ms-ratelimit-microsoft.costmanagement-entity-retry-after': '1', + }, + 1, + ), + ).toBe(1000); + }); + + test('should use Azure Retail Prices retry header delay', () => { + expect( + getRetryDelayMs( + { + 'x-ms-ratelimit-retailprices-retry-after': '60', + }, + 1, + ), + ).toBe(60000); + }); + + test('should use the longest Azure Cost Management retry header value', () => { + expect( + getRetryDelayMs( + { + 'x-ms-ratelimit-microsoft.costmanagement-entity-retry-after': '3', + 'x-ms-ratelimit-microsoft.costmanagement-clienttype-retry-after': '1', + }, + 1, + ), + ).toBe(3000); + }); + + test('should retry 429 responses with fallback backoff when retry header is missing', () => { + expect(getRetryDelayMs({}, 1)).toBe(60000); + }); + + test('should not retry non-429 responses', async () => { + jest + .spyOn(axios, 'request') + .mockRejectedValueOnce(buildAxiosError({ status: 400 })); + + await expect( + customAzureApiCallAction.run(createContext() as any), + ).rejects.toBeInstanceOf(HttpError); + }); +}); + +function createContext(overrides?: Record) { + return { + auth: { + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + }, + propsValue: { + useHostSession: { useHostSessionCheckbox: false }, + subscriptions: {}, + url: { + url: 'https://management.azure.com/subscriptions/sub-id/providers/Microsoft.CostManagement/query?api-version=2023-11-01', + }, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + queryParams: {}, + body: { + type: 'ActualCost', + timeframe: 'LastMonth', + }, + ...overrides, + }, + }; +} + +function buildAxiosError({ + status, + headers = {}, +}: { + status: number; + headers?: Record; +}) { + return { + isAxiosError: true, + response: { + status, + headers, + data: { + error: { + code: String(status), + message: 'Request failed', + }, + }, + }, + }; +} diff --git a/packages/blocks/common/src/lib/http/core/http-error.ts b/packages/blocks/common/src/lib/http/core/http-error.ts index d13fa90888..831200b726 100644 --- a/packages/blocks/common/src/lib/http/core/http-error.ts +++ b/packages/blocks/common/src/lib/http/core/http-error.ts @@ -1,4 +1,5 @@ import { AxiosError } from 'axios'; +import { HttpHeaders } from './http-headers'; export class HttpError extends Error { constructor( @@ -9,6 +10,7 @@ export class HttpError extends Error { JSON.stringify({ response: { status: _err?.response?.status || 500, + headers: (_err?.response?.headers as HttpHeaders | undefined) ?? {}, body: _err?.response?.data, }, request: { @@ -22,6 +24,8 @@ export class HttpError extends Error { return { response: { status: this._err?.response?.status || 500, + headers: + (this._err?.response?.headers as HttpHeaders | undefined) ?? {}, body: this._err?.response?.data, }, request: { @@ -33,6 +37,7 @@ export class HttpError extends Error { get response() { return { status: this._err?.response?.status || 500, + headers: (this._err?.response?.headers as HttpHeaders | undefined) ?? {}, body: this._err?.response?.data, }; } From e351faf442a9dc48fbb54bef8ed8cc034c95148d Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 6 May 2026 11:48:40 +0530 Subject: [PATCH 2/5] Use shared custom api helper --- .../lib/actions/custom-azure-api-action.ts | 96 ++----------------- .../blocks/common/src/lib/helpers/index.ts | 10 ++ 2 files changed, 18 insertions(+), 88 deletions(-) diff --git a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts index 87298df55f..f2bc9f880a 100644 --- a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts +++ b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts @@ -1,12 +1,11 @@ import { + createCustomApiCallAction, + httpClient, HttpError, HttpHeaders, - HttpMethod, HttpRequest, - QueryParams, - httpClient, } from '@openops/blocks-common'; -import { Property, createAction } from '@openops/blocks-framework'; +import { Property } from '@openops/blocks-framework'; import { azureAuth, getUseHostSessionProperty } from '@openops/common'; import { getAzureAccessToken } from '../auth/get-azure-access-token'; import { getSubscriptionsDropdownForHostSession } from '../common-properties'; @@ -22,13 +21,13 @@ const RETRY_AFTER_HEADERS = [ 'retry-after', ]; -export const customAzureApiCallAction = createAction({ +export const customAzureApiCallAction = createCustomApiCallAction({ auth: azureAuth, + baseUrl: () => DEFAULT_BASE_URL, name: 'custom_azure_api_call', description: 'Make a custom REST API call to Azure.', displayName: 'Custom Azure API Call', - isWriteAction: true, - props: { + additionalProps: { documentation: Property.MarkDown({ value: 'For more information, visit the [Azure API documentation](https://learn.microsoft.com/rest/api/azure/).', @@ -54,88 +53,9 @@ export const customAzureApiCallAction = createAction({ return {}; }, }), - url: Property.DynamicProperties({ - displayName: '', - required: true, - refreshers: ['auth'], - props: async () => { - return { - url: Property.ShortText({ - displayName: 'URL', - description: 'The full URL to use, including the base URL', - required: true, - defaultValue: DEFAULT_BASE_URL, - }), - }; - }, - }), - method: Property.StaticDropdown({ - displayName: 'Method', - required: true, - options: { - options: Object.values(HttpMethod).map((value) => ({ - label: value, - value, - })), - }, - }), - headers: Property.Object({ - displayName: 'Headers', - description: - 'Authorization headers are injected automatically from your connection.', - required: false, - }), - queryParams: Property.Object({ - displayName: 'Query Parameters', - required: false, - }), - body: Property.Json({ - displayName: 'Body', - required: false, - }), - failsafe: Property.Checkbox({ - displayName: 'No Error on Failure', - required: false, - }), - timeout: Property.Number({ - displayName: 'Timeout (in seconds)', - required: false, - }), - }, - run: async (context) => { - const { method, url, headers, queryParams, body, failsafe, timeout } = - context.propsValue; - - const urlValue = url?.['url']; - if (!method || !urlValue) { - throw new Error('Method and URL are required.'); - } - - let headersValue = (headers as HttpHeaders | undefined) ?? {}; - const authHeaders = await getAuthHeaders(context); - headersValue = { - ...headersValue, - ...authHeaders, - }; - - const request: HttpRequest> = { - method, - url: urlValue, - headers: headersValue, - queryParams: (queryParams as QueryParams | undefined) ?? {}, - timeout: timeout ? timeout * 1000 : 0, - ...(body ? { body } : {}), - }; - - try { - return await sendWithRetry(request); - } catch (error) { - if (failsafe && error instanceof HttpError) { - return error.errorMessage(); - } - throw error; - } }, + authMapping: getAuthHeaders, + requestHandler: sendWithRetry, }); async function getAuthHeaders(context: any): Promise { diff --git a/packages/blocks/common/src/lib/helpers/index.ts b/packages/blocks/common/src/lib/helpers/index.ts index 2b1f3b3e55..cc51ce3cd9 100644 --- a/packages/blocks/common/src/lib/helpers/index.ts +++ b/packages/blocks/common/src/lib/helpers/index.ts @@ -26,6 +26,10 @@ export const getAccessTokenOrThrow = ( return accessToken; }; +type CustomApiCallRequestHandler = ( + request: HttpRequest>, +) => Promise; + export function createCustomApiCallAction({ auth, baseUrl, @@ -34,6 +38,7 @@ export function createCustomApiCallAction({ displayName, name, additionalProps, + requestHandler, }: { auth?: BlockAuthProperty; baseUrl: (auth?: unknown) => string; @@ -45,6 +50,7 @@ export function createCustomApiCallAction({ displayName?: string | null; name?: string | null; additionalProps?: Record; + requestHandler?: CustomApiCallRequestHandler; }) { return createAction({ name: name ? name : 'custom_api_call', @@ -139,6 +145,10 @@ export function createCustomApiCallAction({ } try { + if (requestHandler) { + return await requestHandler(request); + } + return await httpClient.sendRequest(request); } catch (error) { if (failsafe) { From b56a5c5fa1c28c722d3072c683b5d6063e444cf3 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 6 May 2026 12:19:44 +0530 Subject: [PATCH 3/5] Fix test failure --- packages/blocks/common/src/lib/http/core/http-error.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/blocks/common/src/lib/http/core/http-error.ts b/packages/blocks/common/src/lib/http/core/http-error.ts index 831200b726..9604da28a2 100644 --- a/packages/blocks/common/src/lib/http/core/http-error.ts +++ b/packages/blocks/common/src/lib/http/core/http-error.ts @@ -10,7 +10,6 @@ export class HttpError extends Error { JSON.stringify({ response: { status: _err?.response?.status || 500, - headers: (_err?.response?.headers as HttpHeaders | undefined) ?? {}, body: _err?.response?.data, }, request: { @@ -24,8 +23,6 @@ export class HttpError extends Error { return { response: { status: this._err?.response?.status || 500, - headers: - (this._err?.response?.headers as HttpHeaders | undefined) ?? {}, body: this._err?.response?.data, }, request: { From a087a781d56523bf89f3c314e0626c6da2ed8045 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 6 May 2026 12:48:39 +0530 Subject: [PATCH 4/5] Revert unnecessary changes --- .../lib/actions/custom-azure-api-action.ts | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts index f2bc9f880a..6c3cee236a 100644 --- a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts +++ b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts @@ -10,7 +10,6 @@ import { azureAuth, getUseHostSessionProperty } from '@openops/common'; import { getAzureAccessToken } from '../auth/get-azure-access-token'; import { getSubscriptionsDropdownForHostSession } from '../common-properties'; -const DEFAULT_BASE_URL = 'https://management.azure.com/?api-version=2025-04-01'; const DEFAULT_RETRY_DELAY_MS = 60000; const MAX_RETRY_ATTEMPTS = 4; const COST_MANAGEMENT_RETRY_AFTER_HEADER_PATTERN = @@ -23,7 +22,7 @@ const RETRY_AFTER_HEADERS = [ export const customAzureApiCallAction = createCustomApiCallAction({ auth: azureAuth, - baseUrl: () => DEFAULT_BASE_URL, + baseUrl: () => 'https://management.azure.com/?api-version=2025-04-01', name: 'custom_azure_api_call', description: 'Make a custom REST API call to Azure.', displayName: 'Custom Azure API Call', @@ -54,28 +53,26 @@ export const customAzureApiCallAction = createCustomApiCallAction({ }, }), }, - authMapping: getAuthHeaders, + authMapping: async (context: any) => { + const shouldUseHostCredentials = + context.propsValue.useHostSession?.['useHostSessionCheckbox']; + const selectedSubscription = + context.propsValue?.subscriptions?.['subDropdown']; + + const token = await getAzureAccessToken( + context.auth, + !!shouldUseHostCredentials, + selectedSubscription, + ); + + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + }, requestHandler: sendWithRetry, }); -async function getAuthHeaders(context: any): Promise { - const shouldUseHostCredentials = - context.propsValue.useHostSession?.['useHostSessionCheckbox']; - const selectedSubscription = - context.propsValue?.subscriptions?.['subDropdown']; - - const token = await getAzureAccessToken( - context.auth, - !!shouldUseHostCredentials, - selectedSubscription, - ); - - return { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }; -} - async function sendWithRetry( request: HttpRequest>, ): Promise { From 781ef19b15b5043a3683d1a1242430a9135451ee Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Wed, 6 May 2026 15:16:33 +0530 Subject: [PATCH 5/5] Clean up some code --- .../src/lib/actions/custom-azure-api-action.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts index 6c3cee236a..44640ec33b 100644 --- a/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts +++ b/packages/blocks/azure/src/lib/actions/custom-azure-api-action.ts @@ -14,11 +14,10 @@ const DEFAULT_RETRY_DELAY_MS = 60000; const MAX_RETRY_ATTEMPTS = 4; const COST_MANAGEMENT_RETRY_AFTER_HEADER_PATTERN = /^x-ms-ratelimit-microsoft\.costmanagement.*-retry-after$/i; -const RETRY_AFTER_HEADERS = [ +const AZURE_RETRY_AFTER_HEADERS = new Set([ 'x-ms-ratelimit-microsoft.consumption-retry-after', 'x-ms-ratelimit-retailprices-retry-after', - 'retry-after', -]; +]); export const customAzureApiCallAction = createCustomApiCallAction({ auth: azureAuth, @@ -118,7 +117,7 @@ function getHeaderRetryDelayMs( for (const [headerName, headerValue] of Object.entries(headers)) { if ( - RETRY_AFTER_HEADERS.includes(headerName.toLowerCase()) || + AZURE_RETRY_AFTER_HEADERS.has(headerName.toLowerCase()) || COST_MANAGEMENT_RETRY_AFTER_HEADER_PATTERN.test(headerName) ) { retryValues.push(...normalizeHeaderValues(headerValue)); @@ -156,17 +155,12 @@ function parseRetryDelayMs(headerValue: string): number | null { return null; } - const retryAfterSeconds = Number.parseInt(trimmedValue, 10); - if (Number.isFinite(retryAfterSeconds)) { - return retryAfterSeconds * 1000; - } - - const retryDate = Date.parse(trimmedValue); - if (Number.isNaN(retryDate)) { + const retryAfterSeconds = Number(trimmedValue); + if (!Number.isFinite(retryAfterSeconds)) { return null; } - return Math.max(retryDate - Date.now(), DEFAULT_RETRY_DELAY_MS); + return retryAfterSeconds * 1000; } function sleep(delayMs: number): Promise {