fix(core): cap idempotencyKey length at the API boundary (#3560)

`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, "<field> 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 `<Note>` 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) <noreply@anthropic.com>
This commit is contained in:
Daniel Sutton
2026-05-14 13:24:50 +01:00
committed by GitHub
parent 8ba067d8b0
commit 09f5354a03
5 changed files with 235 additions and 8 deletions
+5
View File
@@ -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.
@@ -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(),
+4
View File
@@ -108,6 +108,10 @@ When you pass a raw string, it defaults to `"run"` scope (scoped to the parent r
<Note>Make sure you provide sufficiently unique keys to avoid collisions.</Note>
<Note>
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`.
</Note>
You can pass the `idempotencyKey` when calling `batchTrigger` as well:
```ts
+49 -7
View File
@@ -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<typeof CreateWaitpointTok
export const CreateInputStreamWaitpointRequestBody = z.object({
streamId: z.string(),
timeout: z.string().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(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
/**
@@ -1422,7 +1452,13 @@ export const CreateSessionStreamWaitpointRequestBody = z.object({
session: z.string(),
io: z.enum(["out", "in"]),
timeout: z.string().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(),
tags: z.union([z.string(), z.array(z.string())]).optional(),
/**
@@ -1711,7 +1747,13 @@ export const WaitForDurationRequestBody = 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.
@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import {
BatchTriggerTaskItem,
CreateBatchRequestBody,
CreateInputStreamWaitpointRequestBody,
CreateSessionStreamWaitpointRequestBody,
CreateWaitpointTokenRequestBody,
TriggerTaskRequestBody,
WaitForDurationRequestBody,
} from "./api.js";
// These tests verify the zod-level character cap (.max(2048)) on schemas whose
// idempotencyKey lands against a unique composite index downstream. The cap
// itself is a JS-string-length check, so the constants below are chosen to
// exercise the boundary cleanly — high entropy isn't required for this layer.
const TOO_LONG = "x".repeat(3000);
const AT_LIMIT = "x".repeat(2048);
const SDK_HASH = "a".repeat(64); // shape of idempotencyKeys.create() output
describe("idempotencyKey length validation", () => {
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");
}
});
});
});