diff --git a/.gitignore b/.gitignore index ec78be6..33c661b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ docker-compose.override.yml .cypress/ playwright-report/ test-output/ +.claude/* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 18dc28f..b73f9d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,10 @@ "@aws-sdk/client-s3": "^3.879.0", "@aws-sdk/client-sqs": "^3.864.0", "@aws-sdk/lib-storage": "^3.879.0", + "@mondaydotcomorg/api": "^14.0.0", "aws-sdk": "^2.1692.0", "axios": "^1.11.0", "http-status-codes": "^2.3.0", - "monday-sdk-js": "^0.5.6", "sqs-consumer": "^12.0.0", "uuid": "^11.1.0" }, @@ -1898,6 +1898,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2084,6 +2093,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mondaydotcomorg/api": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@mondaydotcomorg/api/-/api-14.0.0.tgz", + "integrity": "sha512-crQd8yxyhFZXHfjxN3Zhb7jv6eZ1grMSN9/gOjfZCKQtolTi+u/sfm0o2abioJqdIF66+GPit+yrH4BHHOAnxA==", + "license": "MIT", + "dependencies": { + "graphql": "16.8.2", + "graphql-request": "^6.1.0", + "graphql-tag": "^2.12.6", + "zod": "3.24.4" + }, + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4602,6 +4626,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5561,6 +5594,43 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.8.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.2.tgz", + "integrity": "sha512-cvVIBILwuoSyD54U4cF/UXDh5yAobhNV/tPygI4lZhgOIJQE/WLWC4waBRb4I6bDVYb3OVx3lfHbaQOEoUD5sg==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", + "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "cross-fetch": "^3.1.5" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6325,15 +6395,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/monday-sdk-js": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/monday-sdk-js/-/monday-sdk-js-0.5.6.tgz", - "integrity": "sha512-+jxffW5tgXuFilfJVZL4VMLv7H0PJgV3R9qfKDb4gnCPVZXLPVAUT8vwljlv7D8ozPuoHm8xJ4vofg+1t2ggmg==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.6.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8182,6 +8243,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 3552976..4b5d64b 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "@aws-sdk/client-s3": "^3.879.0", "@aws-sdk/client-sqs": "^3.864.0", "@aws-sdk/lib-storage": "^3.879.0", + "@mondaydotcomorg/api": "^14.0.0", "aws-sdk": "^2.1692.0", "axios": "^1.11.0", "http-status-codes": "^2.3.0", - "monday-sdk-js": "^0.5.6", "sqs-consumer": "^12.0.0", "uuid": "^11.1.0" }, diff --git a/src/constants/monday-error-codes.ts b/src/constants/monday-error-codes.ts new file mode 100644 index 0000000..9f98106 --- /dev/null +++ b/src/constants/monday-error-codes.ts @@ -0,0 +1,33 @@ +import { StatusCodes } from "http-status-codes"; + +export enum MondayErrorCodes { + COMPLEXITY_BUDGET_EXHAUSTED = "COMPLEXITY_BUDGET_EXHAUSTED", + MAX_CONCURRENCY_EXCEEDED = "MAX_CONCURRENCY_EXCEEDED", +} + +export const TERMINAL_MONDAY_ERROR_CODES: ReadonlySet = new Set([ + "ColumnValueException", + "CorrectedValueException", + "CreateBoardException", + "InvalidArgumentException", + "InvalidBoardIdException", + "InvalidColumnIdException", + "InvalidUserIdException", + "InvalidVersionException", + "ItemNameTooLongException", + "ItemsLimitationException", + "missingRequiredPermissions", + "ResourceNotFoundException", + "JsonParseException", + "Unauthorized", + "UserUnauthorizedException", + "USER_ACCESS_DENIED", + "DeleteLastGroupException", + "RecordInvalidException", + "INVALID_QUERY", +]); + +export const RETRYABLE_HTTP_STATUS_CODES: ReadonlySet = new Set([ + StatusCodes.LOCKED, + StatusCodes.TOO_MANY_REQUESTS, +]); diff --git a/src/index.ts b/src/index.ts index ee6b073..82e20fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,7 @@ export type { MondayApiResponse, } from "./types/monday-types"; -export { MondayErrorCodes } from "./types/monday-types"; +export { MondayErrorCodes } from "./constants/monday-error-codes"; export type { ApiProcessorConfig, diff --git a/src/producers/sqs/sqs-producer.ts b/src/producers/sqs/sqs-producer.ts index edca57e..0b51b22 100644 --- a/src/producers/sqs/sqs-producer.ts +++ b/src/producers/sqs/sqs-producer.ts @@ -7,11 +7,12 @@ import { } from "../../types/queue-types"; import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; -// SQS SendMessage allows DelaySeconds in the range [0, 900]. const SQS_MIN_DELAY_SECONDS = 0; const SQS_MAX_DELAY_SECONDS = 900; -function clampDelaySeconds(delay: number | undefined): number | undefined { +export function clampDelaySeconds( + delay: number | undefined, +): number | undefined { if (delay !== undefined) { return Math.min( Math.max(delay, SQS_MIN_DELAY_SECONDS), diff --git a/src/queue-processors/api-request/api-request-queue-processor.ts b/src/queue-processors/api-request/api-request-queue-processor.ts index 55a49a7..d241496 100644 --- a/src/queue-processors/api-request/api-request-queue-processor.ts +++ b/src/queue-processors/api-request/api-request-queue-processor.ts @@ -10,7 +10,8 @@ import { JobError } from "../../errors/job-error"; import { buildErrorContext, calculateBackoffDelay, - hasRetryableError, + getRetryAfterSeconds, + isRetryableMondayError, } from "../../utils/retry-utils"; export class ApiRequestQueueProcessor extends BaseQueueProcessor< @@ -33,24 +34,16 @@ export class ApiRequestQueueProcessor extends BaseQueueProcessor< } private getRetryAfterValue(error: MondayError, attempts: number): number { - const mondayErrors = error.mondayErrors || []; - const mondayErrorExtension = mondayErrors.find(hasRetryableError); - if ( - mondayErrorExtension && - mondayErrorExtension.extensions && - mondayErrorExtension.extensions.retry_in_seconds - ) { - return mondayErrorExtension.extensions.retry_in_seconds; - } - return calculateBackoffDelay(attempts); + return ( + getRetryAfterSeconds(error.mondayErrors) ?? + calculateBackoffDelay(attempts) + ); } private shouldRetryApiCall(error: unknown): boolean { if (error instanceof MondayError) { const mondayErrors = error.mondayErrors || []; - return mondayErrors.some((error) => { - return hasRetryableError(error); - }); + return mondayErrors.some(isRetryableMondayError); } // unknown error, assume retryable return true; diff --git a/src/tasks/call-monday-api-task/monday-client-call-monday-api-task.ts b/src/tasks/call-monday-api-task/monday-client-call-monday-api-task.ts index e142d4c..c070cff 100644 --- a/src/tasks/call-monday-api-task/monday-client-call-monday-api-task.ts +++ b/src/tasks/call-monday-api-task/monday-client-call-monday-api-task.ts @@ -1,15 +1,21 @@ +import { ApiClient, ClientError } from "@mondaydotcomorg/api"; import { BaseCallMondayApiTask } from "./base-call-monday-api-task"; import { CallEndpointResult } from "../../types/task-types"; import { StatusCodes } from "http-status-codes"; -import initMondayClient, { MondayServerSdk } from "monday-sdk-js"; import { MondayError } from "../../errors/monday-error"; +export interface MondayClientCallMondayApiTaskOptions { + apiVersion?: string; +} + export class MondayClientCallMondayApiTask extends BaseCallMondayApiTask { - private readonly mondayClient: MondayServerSdk; - constructor() { + private readonly apiVersion?: string; + + constructor(options: MondayClientCallMondayApiTaskOptions = {}) { super(); - this.mondayClient = initMondayClient() as unknown as MondayServerSdk; + this.apiVersion = options.apiVersion; } + async executeMondayRequest( token: string, query: string, @@ -19,30 +25,35 @@ export class MondayClientCallMondayApiTask extends BaseCallMondayApiTask { if (!token) { throw new Error("API key is required"); } - const response = await this.mondayClient.api(query, { variables, token }); - - if (response.errors && response.errors.length > 0) { - throw new MondayError("Monday.com API returned application errors", { - response, - mondayErrors: response.errors, - partialData: response.data, - }); - } - + const client = new ApiClient({ token, apiVersion: this.apiVersion }); + const data = await client.request(query, variables); return { success: true, status: StatusCodes.OK, - data: response, + data, }; } catch (error) { + const mondayError = normalizeClientError(error); return { success: false, status: - error instanceof MondayError + mondayError instanceof MondayError ? StatusCodes.OK : StatusCodes.INTERNAL_SERVER_ERROR, - error: error, + error: mondayError, }; } } } + +export function normalizeClientError(error: unknown): unknown { + if (!(error instanceof ClientError)) { + return error; + } + const { response } = error; + return new MondayError("Monday.com API returned application errors", { + response, + mondayErrors: response?.errors, + partialData: response?.data, + }); +} diff --git a/src/types/monday-types.ts b/src/types/monday-types.ts index 6e67488..39b65b5 100644 --- a/src/types/monday-types.ts +++ b/src/types/monday-types.ts @@ -9,28 +9,24 @@ export interface MondayQueueRequestMessage extends BasicMondayRequestMessage { } export interface MondayErrorExtensions { - code: string; + code?: string; + error_code?: string; complexity?: number; complexity_budget_left?: number; complexity_budget_limit?: number; retry_in_seconds?: number; - status_code: number; + status_code?: number; + [key: string]: unknown; } export interface MondayApiError { message: string; - locations?: Array<{ line: number; column: number }>; - path?: string[]; + locations?: ReadonlyArray<{ line: number; column: number }>; + path?: ReadonlyArray; extensions?: MondayErrorExtensions; - [key: string]: unknown; } export interface MondayApiResponse { data?: unknown; errors?: Array; } - -export enum MondayErrorCodes { - COMPLEXITY_BUDGET_EXHAUSTED = "COMPLEXITY_BUDGET_EXHAUSTED", - MAX_CONCURRENCY_EXCEEDED = "MAX_CONCURRENCY_EXCEEDED", -} diff --git a/src/utils/retry-utils.ts b/src/utils/retry-utils.ts index 8a0890f..fbaf685 100644 --- a/src/utils/retry-utils.ts +++ b/src/utils/retry-utils.ts @@ -1,4 +1,12 @@ +import { StatusCodes } from "http-status-codes"; import { MondayError } from "../errors/monday-error"; +import { MondayApiError } from "../types/monday-types"; +import { + RETRYABLE_HTTP_STATUS_CODES, + TERMINAL_MONDAY_ERROR_CODES, +} from "../constants/monday-error-codes"; + +export { TERMINAL_MONDAY_ERROR_CODES } from "../constants/monday-error-codes"; export function buildErrorContext( error: unknown, @@ -36,19 +44,32 @@ export function calculateBackoffDelay( return Math.ceil(delayMs / 1000); // Convert to seconds for SQS } -export function hasRetryableError(error: { - extensions?: { status_code?: number; code?: string }; -}): boolean { - const extensions = error.extensions; - if (!extensions) return false; - if (extensions.status_code && extensions.status_code === 429) { - return true; - } - if (extensions.code) { - return ( - extensions.code === "MAX_CONCURRENCY_EXCEEDED" || - extensions.code === "COMPLEXITY_BUDGET_EXHAUSTED" - ); +function isRetryableStatusCode(status: number): boolean { + if (RETRYABLE_HTTP_STATUS_CODES.has(status)) return true; + if (status >= StatusCodes.INTERNAL_SERVER_ERROR) return true; + if (status >= StatusCodes.BAD_REQUEST) return false; + return true; +} + +function isTerminalCode(code: string | undefined): boolean { + return !!code && TERMINAL_MONDAY_ERROR_CODES.has(code); +} + +export function isRetryableMondayError(error: MondayApiError): boolean { + const { status_code, code, error_code } = error.extensions ?? {}; + if (typeof status_code === "number" && status_code > 0) { + return isRetryableStatusCode(status_code); } - return false; + return !isTerminalCode(code ?? error_code); +} + +export function getRetryAfterSeconds( + mondayErrors: MondayApiError[] | undefined, +): number | undefined { + const errorWithRetryAfter = mondayErrors?.find( + (e) => + typeof e.extensions?.retry_in_seconds === "number" && + e.extensions.retry_in_seconds > 0, + ); + return errorWithRetryAfter?.extensions?.retry_in_seconds; } diff --git a/test/api-request-queue-processor.test.ts b/test/api-request-queue-processor.test.ts index 19e2631..f274188 100644 --- a/test/api-request-queue-processor.test.ts +++ b/test/api-request-queue-processor.test.ts @@ -283,6 +283,84 @@ describe("ApiRequestQueueProcessor", () => { expect(delay).toBeGreaterThanOrEqual(1); expect(delay).toBeLessThanOrEqual(2); }); + + test("should bypass maxRetries for retryable Monday errors", async () => { + const processorWithZeroRetries = new ApiRequestQueueProcessor({ + callMondayApiTask: mockCallMondayApiTask, + fileUploadTask: mockFileUploadTask, + pushToCallbackQueueTask: mockPushToCallbackQueueTask, + requeueTask: mockRequeueTask, + maxRetries: 0, + shouldRetryOnGeneralError: false, + }); + + const retryableError = new MondayError("Rate limited", { + message: "Rate limit", + mondayErrors: [ + { + extensions: { + code: MondayErrorCodes.MAX_CONCURRENCY_EXCEEDED, + status_code: 429, + }, + }, + ], + }); + + (mockCallMondayApiTask.execute as Mock).mockResolvedValue({ + success: false, + error: retryableError, + }); + (mockRequeueTask.execute as Mock).mockResolvedValue({ + success: true, + data: { messageId: "retry-bypass", success: true }, + }); + + // Job has already exhausted maxRetries (attempts=5 with maxRetries=0). + // Without bypassRetryLimit, this should be terminal. With it, retry continues. + await processorWithZeroRetries.process({ ...validJob, attempts: 5 }); + + expect(mockRequeueTask.execute).toHaveBeenCalledWith( + { ...validJob, attempts: 6 }, + { delay: expect.any(Number) }, + ); + }); + + test("should retry on INTERNAL_SERVER_ERROR with mixed code/error_code shapes (production regression)", async () => { + const productionShape = new MondayError("Internal Server Error", { + message: "Internal Server Error", + mondayErrors: [ + { + message: "Internal Server Error", + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }, + { + message: "Internal server error", + path: [], + extensions: { + status_code: 500, + error_code: "INTERNAL_SERVER_ERROR", + service: "monolith", + }, + }, + ], + }); + + (mockCallMondayApiTask.execute as Mock).mockResolvedValue({ + success: false, + error: productionShape, + }); + (mockRequeueTask.execute as Mock).mockResolvedValue({ + success: true, + data: { messageId: "retry-prod-bug", success: true }, + }); + + await processor.process(validJob); + + expect(mockRequeueTask.execute).toHaveBeenCalledWith( + { ...validJob, attempts: 1 }, + { delay: expect.any(Number) }, + ); + }); }); describe("process - file upload failures", () => { diff --git a/test/monday-client-call-monday-api-task.test.ts b/test/monday-client-call-monday-api-task.test.ts new file mode 100644 index 0000000..0b0cc08 --- /dev/null +++ b/test/monday-client-call-monday-api-task.test.ts @@ -0,0 +1,110 @@ +import { expect, test, describe } from "vitest"; +import { ClientError } from "@mondaydotcomorg/api"; +import { StatusCodes } from "http-status-codes"; +import { normalizeClientError } from "../src/tasks/call-monday-api-task/monday-client-call-monday-api-task"; +import { MondayError } from "../src/errors/monday-error"; +import { + isRetryableMondayError, +} from "../src/utils/retry-utils"; + +function makeClientError(response: { + data?: unknown; + errors?: Array<{ message: string; extensions?: Record }>; + status?: number; +}): ClientError { + return new ClientError( + { + data: response.data, + errors: response.errors as never, + status: response.status ?? 200, + headers: new Headers(), + } as never, + { query: "test" } as never, + ); +} + +describe("normalizeClientError", () => { + test("non-ClientError input passes through unchanged", () => { + const networkError = new Error("ECONNRESET"); + expect(normalizeClientError(networkError)).toBe(networkError); + }); + + test("non-Error input passes through unchanged", () => { + expect(normalizeClientError("just a string")).toBe("just a string"); + expect(normalizeClientError(undefined)).toBe(undefined); + }); + + test("wraps ClientError into MondayError with mondayErrors populated", () => { + const clientError = makeClientError({ + errors: [ + { + message: "Invalid query", + extensions: { code: "INVALID_QUERY", status_code: StatusCodes.BAD_REQUEST }, + }, + ], + }); + + const result = normalizeClientError(clientError); + + expect(result).toBeInstanceOf(MondayError); + const mondayError = result as MondayError; + expect(mondayError.mondayErrors).toHaveLength(1); + expect(mondayError.mondayErrors?.[0].extensions?.code).toBe("INVALID_QUERY"); + expect(mondayError.mondayErrors?.[0].extensions?.status_code).toBe( + StatusCodes.BAD_REQUEST, + ); + }); + + test("preserves partialData on the wrapped MondayError", () => { + const clientError = makeClientError({ + data: { boards: [{ id: "123" }] }, + errors: [{ message: "Partial failure", extensions: { code: "INTERNAL_SERVER_ERROR" } }], + }); + + const mondayError = normalizeClientError(clientError) as MondayError; + expect(mondayError.partialData).toEqual({ boards: [{ id: "123" }] }); + }); + + test("wraps ClientError with empty errors array", () => { + const clientError = makeClientError({ + errors: [], + status: StatusCodes.INTERNAL_SERVER_ERROR, + }); + const mondayError = normalizeClientError(clientError) as MondayError; + expect(mondayError).toBeInstanceOf(MondayError); + expect(mondayError.mondayErrors).toEqual([]); + }); + + test("retry policy correctly classifies the wrapped production-bug shape", () => { + const clientError = makeClientError({ + errors: [ + { message: "Internal Server Error", extensions: { code: "INTERNAL_SERVER_ERROR" } }, + { + message: "Internal server error", + extensions: { + status_code: StatusCodes.INTERNAL_SERVER_ERROR, + error_code: "INTERNAL_SERVER_ERROR", + service: "monolith", + }, + }, + ], + status: StatusCodes.OK, + }); + + const mondayError = normalizeClientError(clientError) as MondayError; + const anyRetryable = mondayError.mondayErrors?.some(isRetryableMondayError); + expect(anyRetryable).toBe(true); + }); + + test("retry policy correctly classifies a terminal error wrapped from ClientError", () => { + const clientError = makeClientError({ + errors: [ + { message: "Invalid argument", extensions: { code: "InvalidArgumentException" } }, + ], + }); + + const mondayError = normalizeClientError(clientError) as MondayError; + const anyRetryable = mondayError.mondayErrors?.some(isRetryableMondayError); + expect(anyRetryable).toBe(false); + }); +}); diff --git a/test/retry-utils.test.ts b/test/retry-utils.test.ts new file mode 100644 index 0000000..ea65216 --- /dev/null +++ b/test/retry-utils.test.ts @@ -0,0 +1,253 @@ +import { expect, test, describe } from "vitest"; +import { StatusCodes } from "http-status-codes"; +import { + getRetryAfterSeconds, + isRetryableMondayError, + TERMINAL_MONDAY_ERROR_CODES, +} from "../src/utils/retry-utils"; +import { MondayApiError, MondayErrorExtensions } from "../src/types/monday-types"; + +type FixtureOverrides = Partial & { + noExtensions?: boolean; +}; + +function err(overrides: FixtureOverrides = {}): MondayApiError { + const { noExtensions, ...ext } = overrides; + if (noExtensions) { + return { message: "test" }; + } + return { message: "test", extensions: ext as MondayErrorExtensions }; +} + +describe("isRetryableMondayError", () => { + describe("HTTP status rule", () => { + test.each([ + [StatusCodes.TOO_MANY_REQUESTS, "rate limit"], + [StatusCodes.LOCKED, "resource locked"], + [StatusCodes.INTERNAL_SERVER_ERROR, "5xx"], + [StatusCodes.BAD_GATEWAY, "5xx"], + [StatusCodes.SERVICE_UNAVAILABLE, "5xx"], + [StatusCodes.GATEWAY_TIMEOUT, "5xx"], + ])("status_code %i (%s) is retryable", (status_code) => { + expect(isRetryableMondayError(err({ status_code }))).toBe(true); + }); + + test.each([ + [StatusCodes.BAD_REQUEST, "bad request"], + [StatusCodes.UNAUTHORIZED, "unauthorized"], + [StatusCodes.FORBIDDEN, "forbidden"], + [StatusCodes.NOT_FOUND, "not found"], + [StatusCodes.CONFLICT, "conflict"], + [StatusCodes.UNPROCESSABLE_ENTITY, "unprocessable"], + ])("status_code %i (%s) is terminal", (status_code) => { + expect(isRetryableMondayError(err({ status_code }))).toBe(false); + }); + + test("status_code OK (no terminal code) is retryable", () => { + expect( + isRetryableMondayError(err({ status_code: StatusCodes.OK })), + ).toBe(true); + }); + + test("status_code 0 falls through to denylist branch", () => { + expect(isRetryableMondayError(err({ status_code: 0 }))).toBe(true); + }); + }); + + describe("Code denylist (self-testing via exported Set)", () => { + test.each([...TERMINAL_MONDAY_ERROR_CODES])( + "code %s with no status_code is terminal", + (code) => { + expect(isRetryableMondayError(err({ code }))).toBe(false); + }, + ); + + test.each([...TERMINAL_MONDAY_ERROR_CODES])( + "error_code %s with no status_code is terminal", + (code) => { + expect(isRetryableMondayError(err({ error_code: code }))).toBe(false); + }, + ); + }); + + describe("Field-name handling (code vs error_code)", () => { + test("reads from code", () => { + expect( + isRetryableMondayError(err({ code: "ItemsLimitationException" })), + ).toBe(false); + }); + + test("reads from error_code when code absent", () => { + expect( + isRetryableMondayError(err({ error_code: "ItemsLimitationException" })), + ).toBe(false); + }); + + test("code takes precedence over error_code", () => { + expect( + isRetryableMondayError( + err({ + code: "ItemsLimitationException", + error_code: "SOMETHING_RETRYABLE", + }), + ), + ).toBe(false); + }); + + test("retryable error_code (production log #2 shape)", () => { + expect( + isRetryableMondayError(err({ error_code: "INTERNAL_SERVER_ERROR" })), + ).toBe(true); + }); + }); + + describe("Forward-compat default (unknown → retry)", () => { + test("novel code is retryable", () => { + expect(isRetryableMondayError(err({ code: "SOMETHING_NEW" }))).toBe(true); + }); + + test("novel error_code is retryable", () => { + expect( + isRetryableMondayError(err({ error_code: "SOMETHING_NEW" })), + ).toBe(true); + }); + + test("empty extensions object is retryable", () => { + expect(isRetryableMondayError(err({}))).toBe(true); + }); + + test("missing extensions entirely is retryable", () => { + expect(isRetryableMondayError(err({ noExtensions: true }))).toBe(true); + }); + + test("empty-string code is retryable", () => { + expect(isRetryableMondayError(err({ code: "" }))).toBe(true); + }); + }); + + describe("Documented retryable codes from monday API docs", () => { + test("API_TEMPORARILY_BLOCKED with no status_code is retryable", () => { + expect( + isRetryableMondayError(err({ code: "API_TEMPORARILY_BLOCKED" })), + ).toBe(true); + }); + + test("INTERNAL_SERVER_ERROR with no status_code is retryable (reported bug #1)", () => { + expect( + isRetryableMondayError(err({ code: "INTERNAL_SERVER_ERROR" })), + ).toBe(true); + }); + + test("Internal Server Error with status_code 500 is retryable", () => { + expect( + isRetryableMondayError( + err({ + code: "Internal Server Error", + status_code: StatusCodes.INTERNAL_SERVER_ERROR, + }), + ), + ).toBe(true); + }); + + test("COMPLEXITY_BUDGET_EXHAUSTED with status_code 429 is retryable", () => { + expect( + isRetryableMondayError( + err({ + code: "COMPLEXITY_BUDGET_EXHAUSTED", + status_code: StatusCodes.TOO_MANY_REQUESTS, + }), + ), + ).toBe(true); + }); + + test("MAX_CONCURRENCY_EXCEEDED with status_code 429 is retryable", () => { + expect( + isRetryableMondayError( + err({ + code: "MAX_CONCURRENCY_EXCEEDED", + status_code: StatusCodes.TOO_MANY_REQUESTS, + }), + ), + ).toBe(true); + }); + }); +}); + +describe("getRetryAfterSeconds", () => { + test("returns undefined when input is undefined", () => { + expect(getRetryAfterSeconds(undefined)).toBeUndefined(); + }); + + test("returns undefined for an empty array", () => { + expect(getRetryAfterSeconds([])).toBeUndefined(); + }); + + test("returns undefined when no entry has retry_in_seconds", () => { + expect( + getRetryAfterSeconds([err({ code: "FOO" }), err({ status_code: 500 })]), + ).toBeUndefined(); + }); + + test("returns the retry_in_seconds value when a single entry has it", () => { + expect(getRetryAfterSeconds([err({ retry_in_seconds: 37 })])).toBe(37); + }); + + test("returns the FIRST positive retry_in_seconds when multiple entries have one", () => { + expect( + getRetryAfterSeconds([ + err({ retry_in_seconds: 12 }), + err({ retry_in_seconds: 99 }), + ]), + ).toBe(12); + }); + + test("skips entries without retry_in_seconds and returns the first that has one", () => { + expect( + getRetryAfterSeconds([ + err({ code: "FOO" }), + err({ retry_in_seconds: 42 }), + ]), + ).toBe(42); + }); + + test("ignores retry_in_seconds of 0 (treats as absent)", () => { + expect( + getRetryAfterSeconds([ + err({ retry_in_seconds: 0 }), + err({ retry_in_seconds: 5 }), + ]), + ).toBe(5); + }); + + test("returns undefined when only entry has retry_in_seconds: 0", () => { + expect(getRetryAfterSeconds([err({ retry_in_seconds: 0 })])).toBeUndefined(); + }); + + test("ignores negative retry_in_seconds", () => { + expect( + getRetryAfterSeconds([ + err({ retry_in_seconds: -10 }), + err({ retry_in_seconds: 5 }), + ]), + ).toBe(5); + }); + + test("ignores entries missing extensions entirely", () => { + expect( + getRetryAfterSeconds([ + err({ noExtensions: true }), + err({ retry_in_seconds: 7 }), + ]), + ).toBe(7); + }); + + test("does not mutate the input array", () => { + const input = [ + err({ code: "FOO" }), + err({ retry_in_seconds: 42 }), + ]; + const snapshot = JSON.stringify(input); + getRetryAfterSeconds(input); + expect(JSON.stringify(input)).toBe(snapshot); + }); +}); diff --git a/test/sqs-producer.test.ts b/test/sqs-producer.test.ts new file mode 100644 index 0000000..ce3793c --- /dev/null +++ b/test/sqs-producer.test.ts @@ -0,0 +1,41 @@ +import { expect, test, describe } from "vitest"; +import { clampDelaySeconds } from "../src/producers/sqs/sqs-producer"; + +describe("clampDelaySeconds", () => { + test("returns undefined when input is undefined", () => { + expect(clampDelaySeconds(undefined)).toBeUndefined(); + }); + + test("passes through values within range", () => { + expect(clampDelaySeconds(0)).toBe(0); + expect(clampDelaySeconds(1)).toBe(1); + expect(clampDelaySeconds(60)).toBe(60); + expect(clampDelaySeconds(450)).toBe(450); + expect(clampDelaySeconds(900)).toBe(900); + }); + + test("clamps values above 900 to 900", () => { + expect(clampDelaySeconds(901)).toBe(900); + expect(clampDelaySeconds(910)).toBe(900); + expect(clampDelaySeconds(1000)).toBe(900); + expect(clampDelaySeconds(Number.MAX_SAFE_INTEGER)).toBe(900); + }); + + test("clamps negative values to 0", () => { + expect(clampDelaySeconds(-1)).toBe(0); + expect(clampDelaySeconds(-100)).toBe(0); + expect(clampDelaySeconds(Number.MIN_SAFE_INTEGER)).toBe(0); + }); + + test("clamps Infinity to 900", () => { + expect(clampDelaySeconds(Infinity)).toBe(900); + }); + + test("clamps -Infinity to 0", () => { + expect(clampDelaySeconds(-Infinity)).toBe(0); + }); + + test("propagates NaN rather than silently coercing", () => { + expect(Number.isNaN(clampDelaySeconds(NaN))).toBe(true); + }); +});