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/reset-idempotency-key-64-char.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

`idempotencyKeys.reset()` now works when your idempotency key is itself 64 characters long (for example if you use a hash of your own as the key). Previously any 64-character key was assumed to be already hashed, so passing one along with a `scope` silently ignored the scope and the reset never found a matching run. Keys returned by `idempotencyKeys.create()` continue to be reset exactly as before.
259 changes: 258 additions & 1 deletion packages/core/src/v3/idempotencyKeys.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { describe, it, expect } from "vitest";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { apiClientManager } from "./apiClientManager-api.js";
import {
createIdempotencyKey,
getIdempotencyKeyOptions,
makeIdempotencyKey,
resetIdempotencyKey,
resetIdempotencyKeyCatalog,
} from "./idempotencyKeys.js";
import { digestSHA256 } from "./utils/crypto.js";

describe("idempotencyKeys metadata retention", () => {
it("retains key/scope options for every key created in a run, even beyond 1000", async () => {
Expand Down Expand Up @@ -40,3 +46,254 @@ describe("idempotencyKeys metadata retention", () => {
expect(getIdempotencyKeyOptions(key)).toBeUndefined();
});
});

describe("resetIdempotencyKey", () => {
const digestShapedKey = "a".repeat(64);

let server: Server;
let resetKeys: string[] = [];
/** Keys the server has runs for. `undefined` means "accept every key". */
let existingKeys: Set<string> | undefined;
/** Per-key failure statuses, applied before the existence check. */
let statusByKey: Map<string, number>;

function notFoundMessage(key: string) {
return `No runs found with idempotency key: ${key}`;
}

async function resetAndCaptureKey(
...args: Parameters<typeof resetIdempotencyKey>
): Promise<string> {
resetKeys = [];
await resetIdempotencyKey(...args);
expect(resetKeys).toHaveLength(1);
return resetKeys[0]!;
}

beforeEach(async () => {
resetIdempotencyKeyCatalog();
resetKeys = [];
existingKeys = undefined;
statusByKey = new Map();

server = createServer((req, res) => {
req.resume();
req.on("end", () => {
const match = /^\/api\/v1\/idempotencyKeys\/(.+)\/reset$/.exec(req.url ?? "");
if (!match) {
res.writeHead(404).end();
return;
}

const key = decodeURIComponent(match[1]!);
resetKeys.push(key);

const failWith = statusByKey.get(key);
if (failWith !== undefined) {
res.writeHead(failWith, { "content-type": "application/json" });
res.end(JSON.stringify({ error: `request failed for ${key}` }));
return;
}

if (existingKeys !== undefined && !existingKeys.has(key)) {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: notFoundMessage(key) }));
return;
}

res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: "run_reset" }));
});
});

await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});

apiClientManager.setGlobalAPIClientConfiguration({
baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
accessToken: "tr_test_key",
});
});

afterEach(async () => {
apiClientManager.disable();
resetIdempotencyKeyCatalog();
await new Promise<void>((resolve) => server.close(() => resolve()));
});

it("hashes 64-character key material when an explicit scope is passed", async () => {
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });

// The reset can happen in a different process from the trigger
resetIdempotencyKeyCatalog();

expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "global" })).toBe(created);
});

it("hashes 64-character key material for run scope when an explicit scope is passed", async () => {
const parentRunId = "run_abc123";
const expected = await digestSHA256(`${digestShapedKey}-${parentRunId}`);

expect(
await resetAndCaptureKey("my-task", digestShapedKey, { scope: "run", parentRunId })
).toBe(expected);
});

it("sends a key created with idempotencyKeys.create() unchanged while the catalog knows it", async () => {
const created = await createIdempotencyKey("my-key", { scope: "global" });
existingKeys = new Set([created]);

expect(await resetAndCaptureKey("my-task", created)).toBe(created);
expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created);
});

it("sends a created key unchanged when no scope is passed and the catalog is cold", async () => {
const created = await createIdempotencyKey("my-key", { scope: "global" });

// The reset can happen in a different process from the create
resetIdempotencyKeyCatalog();
existingKeys = new Set([created]);

expect(await resetAndCaptureKey("my-task", created)).toBe(created);
});

it("falls back to the verbatim key when a created key is reset with a scope and the catalog is cold", async () => {
const created = await createIdempotencyKey("my-key", { scope: "global" });

resetIdempotencyKeyCatalog();
existingKeys = new Set([created]);

await resetIdempotencyKey("my-task", created, { scope: "global" });

// The derived hash misses, so the already-hashed key is retried verbatim
expect(resetKeys).toEqual([await digestSHA256(created), created]);
});

it("sends a 64-character key unchanged when no scope is passed", async () => {
expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey);
});

it("resets 64-character material that trigger stored verbatim when no scope is passed", async () => {
// trigger() forwards 64-character material as-is, so that is what the server stored
expect(await makeIdempotencyKey(digestShapedKey)).toBe(digestShapedKey);
existingKeys = new Set([digestShapedKey]);

expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey);
});

it("does not fall back when the derived hash for 64-character material matches", async () => {
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });

resetIdempotencyKeyCatalog();
existingKeys = new Set([created]);

await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" });

expect(resetKeys).toEqual([created]);
});

it("falls back to the verbatim key when the derived hash fails with a 503", async () => {
// The server answers 503, not 404, when it cannot check the buffer for a miss
const created = await createIdempotencyKey("my-key", { scope: "global" });

resetIdempotencyKeyCatalog();
statusByKey.set(await digestSHA256(created), 503);
existingKeys = new Set([created]);

await resetIdempotencyKey(
"my-task",
created,
{ scope: "global" },
{ retry: { maxAttempts: 1 } }
);

expect(resetKeys).toEqual([await digestSHA256(created), created]);
});

it("surfaces the derived key's error when it fails with a 503 and the fallback finds nothing", async () => {
const derived = await digestSHA256(digestShapedKey);
statusByKey.set(derived, 503);
existingKeys = new Set();

await expect(
resetIdempotencyKey(
"my-task",
digestShapedKey,
{ scope: "global" },
{ retry: { maxAttempts: 1 } }
)
).rejects.toMatchObject({ status: 503 });

expect(resetKeys).toEqual([derived, digestShapedKey]);
});

it("surfaces the fallback's error when it fails with something other than a 404", async () => {
const derived = await digestSHA256(digestShapedKey);
statusByKey.set(derived, 404);
statusByKey.set(digestShapedKey, 503);

await expect(
resetIdempotencyKey(
"my-task",
digestShapedKey,
{ scope: "global" },
{ retry: { maxAttempts: 1 } }
)
).rejects.toMatchObject({ status: 503 });

expect(resetKeys).toEqual([derived, digestShapedKey]);
});

it("surfaces the derived key's error when both attempts 404", async () => {
const derived = await digestSHA256(digestShapedKey);
existingKeys = new Set();

await expect(
resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" })
).rejects.toThrow(notFoundMessage(derived));

expect(resetKeys).toEqual([derived, digestShapedKey]);
});

it("hashes key material that is not 64 characters", async () => {
const created = await createIdempotencyKey("my-key", { scope: "global" });
resetIdempotencyKeyCatalog();

expect(await resetAndCaptureKey("my-task", "my-key", { scope: "global" })).toBe(created);
});

it("sends a 64-character key verbatim when run scope cannot be derived", async () => {
const created = await createIdempotencyKey("my-key", { scope: "run" });

resetIdempotencyKeyCatalog();
existingKeys = new Set([created]);

// No parentRunId and no task context, so the hash is underivable
expect(await resetAndCaptureKey("my-task", created, { scope: "run" })).toBe(created);
});

it("sends a 64-character key verbatim when attempt scope cannot be derived", async () => {
existingKeys = new Set([digestShapedKey]);

expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "attempt" })).toBe(
digestShapedKey
);
});

it("still throws for non-64-character material when run scope cannot be derived", async () => {
await expect(resetIdempotencyKey("my-task", "my-key", { scope: "run" })).rejects.toThrow(
"parentRunId is required for 'run' scope"
);

expect(resetKeys).toEqual([]);
});

it("still throws for non-64-character material when attempt scope cannot be derived", async () => {
await expect(
resetIdempotencyKey("my-task", "my-key", { scope: "attempt", parentRunId: "run_abc123" })
).rejects.toThrow("parentRunId and attemptNumber are required for 'attempt' scope");

expect(resetKeys).toEqual([]);
});
});
45 changes: 33 additions & 12 deletions packages/core/src/v3/idempotencyKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { taskContext } from "./task-context-api.js";
import type { IdempotencyKey } from "./types/idempotencyKeys.js";
import { digestSHA256 } from "./utils/crypto.js";
import type { ZodFetchOptions } from "./apiClient/core.js";
import { NotFoundError } from "./apiClient/errors.js";

// Re-export types from catalog for backwards compatibility
export type {
Expand Down Expand Up @@ -234,26 +235,30 @@ export async function resetIdempotencyKey(
): Promise<{ id: string }> {
const client = apiClientManager.clientOrThrow();

// If the key is already a 64-char hash, use it directly
if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
// A 64-char key is only assumed pre-hashed if the catalog knows it, or there's no scope to hash with
const is64CharKey = typeof idempotencyKey === "string" && idempotencyKey.length === 64;

// Try to extract options from an IdempotencyKey created with idempotencyKeys.create()
const attachedOptions =
typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined;
if (is64CharKey) {
const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined;

const scope = attachedOptions?.scope ?? options?.scope ?? "run";
const keyArray = Array.isArray(idempotencyKey)
? idempotencyKey
: [attachedOptions?.key ?? String(idempotencyKey)];
if (isCreatedKey || options?.scope === undefined) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +241 to +247

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 parentRunId/attemptNumber without an explicit scope is still ignored for 64-character keys

The new pass-through condition treats "no scope passed" as evidence the key is already a hash (packages/core/src/v3/idempotencyKeys.ts:244), but a caller can also signal hashing intent by passing only parentRunId (or parentRunId + attemptNumber) and relying on the default scope: "run". For non-64-character material that path derives sha256(key-parentRunId); for 64-character material the key is sent verbatim and no fallback hash is attempted, so the reset can 404 with no retry. This is pre-existing behaviour rather than a regression, and the JSDoc for resetIdempotencyKey (packages/core/src/v3/idempotencyKeys.ts:207-211) does say a raw string "requires options.scope", so it may be intentional — but the new "positive evidence" rule would be more consistent if the presence of parentRunId/attemptNumber also counted as evidence that the caller wants a derived hash.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional, so leaving it. parentRunId is documented as subordinate to scope rather than an independent signal — ResetIdempotencyKeyOptions describes it as "Required if scope is 'run' or 'attempt'" (:198), and the JSDoc at :210 names scope as what the raw-string form requires — so counting it as derivation intent would be a contract change rather than a consistency fix.

It also wouldn't be strictly better. For a 64-character key with only parentRunId, verbatim is the more likely intent, so deriving first would add a guaranteed extra request and a server-side 404 to every such call that works today, and where both a verbatim-keyed and a derived-keyed run exist it would reset the latter instead of the former. Since this is pre-existing rather than a regression here, it's better as a follow-up if someone actually hits it.


Generated by Claude Code


const scope = options?.scope ?? "run";
const keyArray = Array.isArray(idempotencyKey) ? idempotencyKey : [idempotencyKey];

// Build scope suffix based on scope type
let scopeSuffix: string[] = [];
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
switch (scope) {
case "run": {
const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id;
if (!parentRunId) {
// We can't derive a hash, but a 64-char key may already be one, so try it rather than fail
if (is64CharKey) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
throw new Error(
"resetIdempotencyKey: parentRunId is required for 'run' scope when called outside a task context"
);
Expand All @@ -265,6 +270,9 @@ export async function resetIdempotencyKey(
const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id;
const attemptNumber = options?.attemptNumber ?? taskContext?.ctx?.attempt.number;
if (!parentRunId || attemptNumber === undefined) {
if (is64CharKey) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
throw new Error(
"resetIdempotencyKey: parentRunId and attemptNumber are required for 'attempt' scope when called outside a task context"
);
Expand All @@ -277,5 +285,18 @@ export async function resetIdempotencyKey(
// Generate the hash using the same algorithm as createIdempotencyKey
const hash = await generateIdempotencyKey(keyArray.concat(scopeSuffix));

return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
if (!is64CharKey) {
return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
}

// Hashing a 64-char key is a guess, so if it fails at all, still try the key verbatim
try {
return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
} catch (error) {
try {
return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
} catch (fallbackError) {
throw fallbackError instanceof NotFoundError ? error : fallbackError;
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Loading