Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Comment thread
d-cs marked this conversation as resolved.
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