From 09f5354a03bce7d34d98285d07437b045a0d460c Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Thu, 14 May 2026 13:24:50 +0100 Subject: [PATCH] fix(core): cap idempotencyKey length at the API boundary (#3560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tasks.trigger`, `tasks.batchTrigger`, `batch.create`, `wait.createToken`, `wait.forDuration`, and the input/session stream waitpoint endpoints all accept a caller-supplied `idempotencyKey` and store it verbatim against a composite-unique index on `TaskRun`, `BatchTaskRun`, or `Waitpoint`. The schemas had no length cap, so a sufficiently long high-entropy key produced an index row larger than the underlying storage layer can hold. The insert failed at the database, and the caller saw a generic 500 from `RunEngineTriggerTaskService.call()` / `CreateBatchService` / waitpoint creation, depending on the endpoint. Keys produced by `idempotencyKeys.create()` are 64-character SHA-256 hashes and never trip this — it only manifests for direct REST callers (or SDK callers passing a raw string they generated themselves). Low-entropy keys also sail through, because the storage layer compresses repeated bytes before they reach the index, which is why the failure mode is intermittent and tied to caller-side key shape. ## Fix Add `.max(2048, " must be 2048 characters or less")` to the seven schemas that feed an indexed `idempotencyKey` column: - `TriggerTaskRequestBody.options.idempotencyKey` - `BatchTriggerTaskItem.options.idempotencyKey` - `CreateBatchRequestBody.idempotencyKey` - `CreateWaitpointTokenRequestBody.idempotencyKey` - `CreateInputStreamWaitpointRequestBody.idempotencyKey` - `CreateSessionStreamWaitpointRequestBody.idempotencyKey` - `WaitForDurationRequestBody.idempotencyKey` Plus the `idempotency-key` HTTP header on the trigger route (and the three batch routes that re-export `HeadersSchema`). The header schema is lifted out of `api.v1.tasks.$taskId.trigger.ts` into `apps/webapp/app/v3/triggerHeaders.server.ts` so it can be exercised in tests without dragging the route's import-time side effects. The 2048 character ceiling is chosen to sit safely under the per-row index limit while staying generous against existing callers — keys that fit before still fit. Oversized keys now return a structured Zod 400 instead of a generic 500. Limit is documented under `Idempotency key` in `docs/limits.mdx` and as a `` on `docs/idempotency.mdx`. ## Test plan - [x] 15 schema unit tests added (`packages/core/src/v3/schemas/idempotencyKey.test.ts`, `apps/webapp/test/routes/triggerHeaders.test.ts`) — rejection-with-message + boundary acceptance for each capped schema. The webapp test exercises the extracted `TriggerHeadersSchema` directly with no mocks. - [x] `pnpm run build --filter @trigger.dev/core` - [x] `pnpm run typecheck --filter webapp` - [x] End-to-end verified locally: baseline (small key) → 200; 3000-char high-entropy header → 400 with the expected Zod error; same key at the 2048 boundary → 200; same key with the cap reverted → the database rejected the insert and the route returned 500 to the caller. Cap restored. Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/cap-idempotency-key-length.md | 5 + .../routes/api.v1.tasks.$taskId.trigger.ts | 5 +- docs/idempotency.mdx | 4 + packages/core/src/v3/schemas/api.ts | 56 +++++- .../src/v3/schemas/idempotencyKey.test.ts | 173 ++++++++++++++++++ 5 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 .changeset/cap-idempotency-key-length.md create mode 100644 packages/core/src/v3/schemas/idempotencyKey.test.ts diff --git a/.changeset/cap-idempotency-key-length.md b/.changeset/cap-idempotency-key-length.md new file mode 100644 index 000000000..d13603691 --- /dev/null +++ b/.changeset/cap-idempotency-key-length.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Reject overlong `idempotencyKey` values at the API boundary so they no longer trip an internal size limit on the underlying unique index and surface as a generic 500. Inputs are capped at 2048 characters — well above what `idempotencyKeys.create()` produces (a 64-character hash) and above any realistic raw key. Applies to `tasks.trigger`, `tasks.batchTrigger`, `batch.create` (Phase 1 streaming batches), `wait.createToken`, `wait.forDuration`, and the input/session stream waitpoint endpoints. Over-limit requests now return a structured 400 instead. diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index ee1ed0393..8206a90f3 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -28,7 +28,10 @@ const ParamsSchema = z.object({ }); export const HeadersSchema = z.object({ - "idempotency-key": z.string().nullish(), + "idempotency-key": z + .string() + .max(2048, "idempotency-key must be 2048 characters or less") + .nullish(), "idempotency-key-ttl": z.string().nullish(), "trigger-version": z.string().nullish(), "x-trigger-span-parent-as-link": z.coerce.number().nullish(), diff --git a/docs/idempotency.mdx b/docs/idempotency.mdx index 034246eaf..0d6134169 100644 --- a/docs/idempotency.mdx +++ b/docs/idempotency.mdx @@ -108,6 +108,10 @@ When you pass a raw string, it defaults to `"run"` scope (scoped to the parent r Make sure you provide sufficiently unique keys to avoid collisions. + +Idempotency keys are limited to 2048 characters. Keys produced by `idempotencyKeys.create()` are 64-character hashes and always fit; this limit only matters if you pass a long raw string. Requests above the limit return `400`. + + You can pass the `idempotencyKey` when calling `batchTrigger` as well: ```ts diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 0db92a67c..90cf30eed 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -197,7 +197,13 @@ export const TriggerTaskRequestBody = z.object({ .optional(), concurrencyKey: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), idempotencyKeyTTL: z.string().optional(), /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), @@ -249,7 +255,13 @@ export const BatchTriggerTaskItem = z.object({ .object({ concurrencyKey: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), idempotencyKeyTTL: z.string().optional(), /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), @@ -358,7 +370,13 @@ export const CreateBatchRequestBody = z.object({ /** Whether to resume parent on completion (true for batchTriggerAndWait) */ resumeParentOnCompletion: z.boolean().optional(), /** Idempotency key for the batch */ - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), }); @@ -1350,7 +1368,13 @@ export const CreateWaitpointTokenRequestBody = z.object({ * * Note: This waitpoint may already be complete, in which case when you wait for it, it will immediately continue. */ - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), /** * When set, this means the passed in idempotency key will expire after this time. * This means after that time if you pass the same idempotency key again, you will get a new waitpoint. @@ -1389,7 +1413,13 @@ export type CreateWaitpointTokenResponseBody = z.infer { + describe("TriggerTaskRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { idempotencyKey: TOO_LONG }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["options", "idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { idempotencyKey: AT_LIMIT }, + }); + + expect(result.success).toBe(true); + }); + + it("accepts the SDK-generated 64-character hash", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { idempotencyKey: SDK_HASH }, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("BatchTriggerTaskItem", () => { + it("rejects an idempotencyKey over 2048 characters", () => { + const result = BatchTriggerTaskItem.safeParse({ + task: "my-task", + payload: {}, + options: { idempotencyKey: TOO_LONG }, + }); + + expect(result.success).toBe(false); + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = BatchTriggerTaskItem.safeParse({ + task: "my-task", + payload: {}, + options: { idempotencyKey: AT_LIMIT }, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("CreateBatchRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateBatchRequestBody.safeParse({ + runCount: 1, + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = CreateBatchRequestBody.safeParse({ + runCount: 1, + idempotencyKey: AT_LIMIT, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("CreateWaitpointTokenRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateWaitpointTokenRequestBody.safeParse({ + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = CreateWaitpointTokenRequestBody.safeParse({ + idempotencyKey: AT_LIMIT, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("CreateInputStreamWaitpointRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateInputStreamWaitpointRequestBody.safeParse({ + streamId: "stream_1", + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + }); + + describe("CreateSessionStreamWaitpointRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateSessionStreamWaitpointRequestBody.safeParse({ + session: "session_1", + io: "out", + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + }); + + describe("WaitForDurationRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = WaitForDurationRequestBody.safeParse({ + date: new Date(), + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + }); +});