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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user