Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cap-idempotency-key-length.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
d-cs marked this conversation as resolved.
.nullish(),
"idempotency-key-ttl": z.string().nullish(),
"trigger-version": z.string().nullish(),
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
Expand Down
4 changes: 4 additions & 0 deletions docs/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 49 additions & 7 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
/**
Expand Down Expand Up @@ -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(),
/**
Expand Down Expand Up @@ -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.
Expand Down
173 changes: 173 additions & 0 deletions packages/core/src/v3/schemas/idempotencyKey.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
});
Loading