-
Notifications
You must be signed in to change notification settings - Fork 176
Modify Custom Azure API Call action to retry on 429 errors
#2264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4188b48
e351faf
b56a5c5
a087a78
781ef19
9d4d1a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,30 @@ | ||
| import { createCustomApiCallAction } from '@openops/blocks-common'; | ||
| import { | ||
| createCustomApiCallAction, | ||
| httpClient, | ||
| HttpError, | ||
| HttpHeaders, | ||
| HttpRequest, | ||
| } from '@openops/blocks-common'; | ||
| 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'; | ||
|
|
||
| const DEFAULT_RETRY_DELAY_MS = 60000; | ||
| const MAX_RETRY_ATTEMPTS = 4; | ||
| const COST_MANAGEMENT_RETRY_AFTER_HEADER_PATTERN = | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cost Management endpoint returned different types of retry headers patterns like this. So we're using regex. |
||
| /^x-ms-ratelimit-microsoft\.costmanagement.*-retry-after$/i; | ||
| const AZURE_RETRY_AFTER_HEADERS = new Set([ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are the headers we noticed while testing by Dariusz |
||
| 'x-ms-ratelimit-microsoft.consumption-retry-after', | ||
| 'x-ms-ratelimit-retailprices-retry-after', | ||
| ]); | ||
|
|
||
| export const customAzureApiCallAction = createCustomApiCallAction({ | ||
| auth: azureAuth, | ||
| 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', | ||
| baseUrl: () => 'https://management.azure.com/?api-version=2025-04-01', | ||
| additionalProps: { | ||
| documentation: Property.MarkDown({ | ||
| value: | ||
|
|
@@ -54,4 +69,100 @@ export const customAzureApiCallAction = createCustomApiCallAction({ | |
| 'Content-Type': 'application/json', | ||
| }; | ||
| }, | ||
| requestHandler: sendWithRetry, | ||
| }); | ||
|
|
||
| async function sendWithRetry( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of standard http request we use this handler function with retry logic. I've extended |
||
| request: HttpRequest<Record<string, unknown>>, | ||
| ): Promise<unknown> { | ||
| 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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if no retry-headers are matched but the error is 429, we wait for 60 secs and retry |
||
| } | ||
|
|
||
| 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 ( | ||
| AZURE_RETRY_AFTER_HEADERS.has(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); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a response has many retry headers (entity, subscription, tenant) we pick the max delay time and use it. |
||
| } | ||
|
|
||
| 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(trimmedValue); | ||
| if (!Number.isFinite(retryAfterSeconds)) { | ||
| return null; | ||
| } | ||
|
|
||
| return retryAfterSeconds * 1000; | ||
| } | ||
|
|
||
| function sleep(delayMs: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, delayMs)); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>) { | ||
| 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<string, string>; | ||
| }) { | ||
| return { | ||
| isAxiosError: true, | ||
| response: { | ||
| status, | ||
| headers, | ||
| data: { | ||
| error: { | ||
| code: String(status), | ||
| message: 'Request failed', | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { AxiosError } from 'axios'; | ||
| import { HttpHeaders } from './http-headers'; | ||
|
|
||
| export class HttpError extends Error { | ||
| constructor( | ||
|
|
@@ -33,6 +34,7 @@ export class HttpError extends Error { | |
| get response() { | ||
| return { | ||
| status: this._err?.response?.status || 500, | ||
| headers: (this._err?.response?.headers as HttpHeaders | undefined) ?? {}, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without this in http response, we cannot fix this issue. |
||
| body: this._err?.response?.data, | ||
| }; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we get 429 repeatedly even after delay; we limit the consecutive retries to 4.