Skip to content

Commit 518978b

Browse files
claude[bot]claudematt-aitken
authored
fix(core): don't assume a 64-character idempotency key is pre-hashed on reset (#4626)
<!-- ccr-slack-attribution --> _Requested by **Matt Aitken** · [Slack thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1786741966214949?thread_ts=1786741966.214949&cid=C045W9WM3E1)_ `idempotencyKeys.reset()` now honours an explicitly passed `scope` even when the key material happens to be 64 characters long. **Before:** `resetIdempotencyKey` treated *any* 64-character string as an already-computed hash and sent it to the API verbatim. That short-circuit ran before the scope logic, so if your key material is itself a 64-character digest (a common pattern when you hash your own dedup identity) the `scope` you passed was silently discarded and the un-hashed material went on the wire. The server stores the hash, so the reset matched no run and returned 404 every single time. Key material of any other length worked fine, which made this look arbitrary. **After:** a 64-character key with an explicit `scope` is sent verbatim first and, only when that attempt comes back a definitive not-found, retried as the derived scope hash. Every call that worked before behaves identically, and the previously impossible case now resolves on the fallback. ## How A 64-character string is forwarded unchanged, exactly as before, when: - the idempotency key catalog recognises it (it came from `idempotencyKeys.create()` in this process), or - no `scope` was passed, so there is nothing to derive a hash from, or - the scope hash cannot be derived (e.g. `scope: "run"` outside a task context with no `parentRunId`). Otherwise the key is ambiguous: it may be raw material the caller wants hashed with the scope, or it may already be the stored hash. Reset sends the verbatim value first because that is what every previous version sent, so anything that resolved before still resolves with the same single request, the same target run, and the same errors. The derived hash is the new behaviour, so it only runs once the verbatim attempt has failed with a 404, a definitive "no run under this key". Any other error (a 503, a connection error) leaves the verbatim key's state unknown, and resetting a different key on unknown state would be an untargeted write the caller never asked for, so those errors surface unchanged. That has an honest cost: when the endpoint answers 503 for a miss it cannot confirm, the caller sees the 503 and retries rather than silently falling through to the derived key. When both attempts miss, the verbatim attempt's 404 is surfaced, again matching what previous versions threw. A side benefit of this order: a key from `idempotencyKeys.create()` reset with a `scope` from a cold process resolves in a single request, because the created key is itself the stored value. `isIdempotencyKey` is deliberately left alone: it applies the same length rule on the trigger path, but it is self-consistent there, and changing it would invalidate already-stored keys. The `attachedOptions?.key` / `attachedOptions?.scope` fallbacks below the old guard were unreachable (every catalog entry is a 64-character digest, so it always hit the short-circuit first) and re-deriving from them produces the identical hash anyway. They are removed rather than left as dead code. --- ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing Tests in `packages/core/src/v3/idempotencyKeys.test.ts` drive the real `resetIdempotencyKey` against a local HTTP server and assert on the exact values that reach the wire, in order. Nothing is mocked. They cover: - 64-character material + explicit `scope` derives the global- and run-scoped hash once the verbatim key misses (fails without this change) - the verbatim key wins when runs exist under both the verbatim value and the derived hash, so the pre-existing target is preserved - keys from `idempotencyKeys.create()` are forwarded unchanged: catalog hit, no scope, and scope with a cold catalog (the last now a single request) - a transient failure of the verbatim attempt surfaces its error without ever touching the derived key - error surfacing: a double miss reports the key the caller passed, and a non-404 from the fallback is not swallowed - ordinary short material is still hashed, and underivable run/attempt scopes still send a 64-character key verbatim while still throwing for shorter material ``` pnpm run test ./src/v3/idempotencyKeys.test.ts --run # 18 passed pnpm run build --filter @trigger.dev/core # clean pnpm run format && pnpm run lint # clean ``` --- ## Changelog `idempotencyKeys.reset()` now works when your idempotency key is itself 64 characters long. 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. --- ## Follow-ups (not in this PR) - `docs/idempotency.mdx` describes the `idempotencyKey` parameter of `reset()` as "the 64-character hash string" in one place while showing raw material plus `{ scope: "global" }` a few lines later. Worth reconciling. - No surface currently exposes the stored hash that the reset endpoint matches on: `ctx.run.idempotencyKey`, the run page and the `idempotency_key` query column all show the user-provided key. That is what leads people to send a value reset cannot match. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Matt Aitken <matt@mattaitken.com>
1 parent c668b72 commit 518978b

3 files changed

Lines changed: 283 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
`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.

packages/core/src/v3/idempotencyKeys.test.ts

Lines changed: 242 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { describe, it, expect } from "vitest";
1+
import { createServer, type Server } from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { apiClientManager } from "./apiClientManager-api.js";
25
import {
36
createIdempotencyKey,
47
getIdempotencyKeyOptions,
8+
makeIdempotencyKey,
9+
resetIdempotencyKey,
510
resetIdempotencyKeyCatalog,
611
} from "./idempotencyKeys.js";
12+
import { digestSHA256 } from "./utils/crypto.js";
713

814
describe("idempotencyKeys metadata retention", () => {
915
it("retains key/scope options for every key created in a run, even beyond 1000", async () => {
@@ -40,3 +46,238 @@ describe("idempotencyKeys metadata retention", () => {
4046
expect(getIdempotencyKeyOptions(key)).toBeUndefined();
4147
});
4248
});
49+
50+
describe("resetIdempotencyKey", () => {
51+
const digestShapedKey = "a".repeat(64);
52+
53+
let server: Server;
54+
let resetKeys: string[] = [];
55+
/** Keys the server has runs for. `undefined` means "accept every key". */
56+
let existingKeys: Set<string> | undefined;
57+
/** Per-key failure statuses, applied before the existence check. */
58+
let statusByKey: Map<string, number>;
59+
60+
function notFoundMessage(key: string) {
61+
return `No runs found with idempotency key: ${key}`;
62+
}
63+
64+
async function resetAndCaptureKey(
65+
...args: Parameters<typeof resetIdempotencyKey>
66+
): Promise<string> {
67+
resetKeys = [];
68+
await resetIdempotencyKey(...args);
69+
expect(resetKeys).toHaveLength(1);
70+
return resetKeys[0]!;
71+
}
72+
73+
beforeEach(async () => {
74+
resetIdempotencyKeyCatalog();
75+
resetKeys = [];
76+
existingKeys = undefined;
77+
statusByKey = new Map();
78+
79+
server = createServer((req, res) => {
80+
req.resume();
81+
req.on("end", () => {
82+
const match = /^\/api\/v1\/idempotencyKeys\/(.+)\/reset$/.exec(req.url ?? "");
83+
if (!match) {
84+
res.writeHead(404).end();
85+
return;
86+
}
87+
88+
const key = decodeURIComponent(match[1]!);
89+
resetKeys.push(key);
90+
91+
const failWith = statusByKey.get(key);
92+
if (failWith !== undefined) {
93+
res.writeHead(failWith, { "content-type": "application/json" });
94+
res.end(JSON.stringify({ error: `request failed for ${key}` }));
95+
return;
96+
}
97+
98+
if (existingKeys !== undefined && !existingKeys.has(key)) {
99+
res.writeHead(404, { "content-type": "application/json" });
100+
res.end(JSON.stringify({ error: notFoundMessage(key) }));
101+
return;
102+
}
103+
104+
res.writeHead(200, { "content-type": "application/json" });
105+
res.end(JSON.stringify({ id: "run_reset" }));
106+
});
107+
});
108+
109+
await new Promise<void>((resolve) => {
110+
server.listen(0, "127.0.0.1", () => resolve());
111+
});
112+
113+
apiClientManager.setGlobalAPIClientConfiguration({
114+
baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
115+
accessToken: "tr_test_key",
116+
});
117+
});
118+
119+
afterEach(async () => {
120+
apiClientManager.disable();
121+
resetIdempotencyKeyCatalog();
122+
await new Promise<void>((resolve) => server.close(() => resolve()));
123+
});
124+
125+
it("derives the hash for 64-character key material with an explicit scope when the verbatim key misses", async () => {
126+
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });
127+
128+
resetIdempotencyKeyCatalog();
129+
existingKeys = new Set([created]);
130+
131+
await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" });
132+
133+
expect(resetKeys).toEqual([digestShapedKey, created]);
134+
});
135+
136+
it("derives the run-scoped hash for 64-character key material when the verbatim key misses", async () => {
137+
const parentRunId = "run_abc123";
138+
const expected = await digestSHA256(`${digestShapedKey}-${parentRunId}`);
139+
existingKeys = new Set([expected]);
140+
141+
await resetIdempotencyKey("my-task", digestShapedKey, { scope: "run", parentRunId });
142+
143+
expect(resetKeys).toEqual([digestShapedKey, expected]);
144+
});
145+
146+
it("sends a key created with idempotencyKeys.create() unchanged while the catalog knows it", async () => {
147+
const created = await createIdempotencyKey("my-key", { scope: "global" });
148+
existingKeys = new Set([created]);
149+
150+
expect(await resetAndCaptureKey("my-task", created)).toBe(created);
151+
expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created);
152+
});
153+
154+
it("sends a created key unchanged when no scope is passed and the catalog is cold", async () => {
155+
const created = await createIdempotencyKey("my-key", { scope: "global" });
156+
157+
// The reset can happen in a different process from the create
158+
resetIdempotencyKeyCatalog();
159+
existingKeys = new Set([created]);
160+
161+
expect(await resetAndCaptureKey("my-task", created)).toBe(created);
162+
});
163+
164+
it("resolves a created key in one request when reset with a scope and the catalog is cold", async () => {
165+
const created = await createIdempotencyKey("my-key", { scope: "global" });
166+
167+
resetIdempotencyKeyCatalog();
168+
existingKeys = new Set([created]);
169+
170+
expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created);
171+
});
172+
173+
it("sends a 64-character key unchanged when no scope is passed", async () => {
174+
expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey);
175+
});
176+
177+
it("resets 64-character material that trigger stored verbatim when no scope is passed", async () => {
178+
// trigger() forwards 64-character material as-is, so that is what the server stored
179+
expect(await makeIdempotencyKey(digestShapedKey)).toBe(digestShapedKey);
180+
existingKeys = new Set([digestShapedKey]);
181+
182+
expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey);
183+
});
184+
185+
it("resets the verbatim run when runs exist under both the verbatim key and the derived hash", async () => {
186+
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });
187+
188+
resetIdempotencyKeyCatalog();
189+
existingKeys = new Set([digestShapedKey, created]);
190+
191+
await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" });
192+
193+
expect(resetKeys).toEqual([digestShapedKey]);
194+
});
195+
196+
it("does not reset the derived run when the verbatim attempt fails transiently", async () => {
197+
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });
198+
199+
resetIdempotencyKeyCatalog();
200+
statusByKey.set(digestShapedKey, 503);
201+
existingKeys = new Set([created]);
202+
203+
await expect(
204+
resetIdempotencyKey(
205+
"my-task",
206+
digestShapedKey,
207+
{ scope: "global" },
208+
{ retry: { maxAttempts: 1 } }
209+
)
210+
).rejects.toMatchObject({ status: 503 });
211+
212+
expect(resetKeys).toEqual([digestShapedKey]);
213+
});
214+
215+
it("surfaces the fallback's error when it fails with something other than a 404", async () => {
216+
const derived = await digestSHA256(digestShapedKey);
217+
statusByKey.set(digestShapedKey, 404);
218+
statusByKey.set(derived, 503);
219+
220+
await expect(
221+
resetIdempotencyKey(
222+
"my-task",
223+
digestShapedKey,
224+
{ scope: "global" },
225+
{ retry: { maxAttempts: 1 } }
226+
)
227+
).rejects.toMatchObject({ status: 503 });
228+
229+
expect(resetKeys).toEqual([digestShapedKey, derived]);
230+
});
231+
232+
it("surfaces the verbatim key's error when both attempts 404", async () => {
233+
const derived = await digestSHA256(digestShapedKey);
234+
existingKeys = new Set();
235+
236+
await expect(
237+
resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" })
238+
).rejects.toThrow(notFoundMessage(digestShapedKey));
239+
240+
expect(resetKeys).toEqual([digestShapedKey, derived]);
241+
});
242+
243+
it("hashes key material that is not 64 characters", async () => {
244+
const created = await createIdempotencyKey("my-key", { scope: "global" });
245+
resetIdempotencyKeyCatalog();
246+
247+
expect(await resetAndCaptureKey("my-task", "my-key", { scope: "global" })).toBe(created);
248+
});
249+
250+
it("sends a 64-character key verbatim when run scope cannot be derived", async () => {
251+
const created = await createIdempotencyKey("my-key", { scope: "run" });
252+
253+
resetIdempotencyKeyCatalog();
254+
existingKeys = new Set([created]);
255+
256+
// No parentRunId and no task context, so the hash is underivable
257+
expect(await resetAndCaptureKey("my-task", created, { scope: "run" })).toBe(created);
258+
});
259+
260+
it("sends a 64-character key verbatim when attempt scope cannot be derived", async () => {
261+
existingKeys = new Set([digestShapedKey]);
262+
263+
expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "attempt" })).toBe(
264+
digestShapedKey
265+
);
266+
});
267+
268+
it("still throws for non-64-character material when run scope cannot be derived", async () => {
269+
await expect(resetIdempotencyKey("my-task", "my-key", { scope: "run" })).rejects.toThrow(
270+
"parentRunId is required for 'run' scope"
271+
);
272+
273+
expect(resetKeys).toEqual([]);
274+
});
275+
276+
it("still throws for non-64-character material when attempt scope cannot be derived", async () => {
277+
await expect(
278+
resetIdempotencyKey("my-task", "my-key", { scope: "attempt", parentRunId: "run_abc123" })
279+
).rejects.toThrow("parentRunId and attemptNumber are required for 'attempt' scope");
280+
281+
expect(resetKeys).toEqual([]);
282+
});
283+
});

packages/core/src/v3/idempotencyKeys.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { taskContext } from "./task-context-api.js";
88
import type { IdempotencyKey } from "./types/idempotencyKeys.js";
99
import { digestSHA256 } from "./utils/crypto.js";
1010
import type { ZodFetchOptions } from "./apiClient/core.js";
11+
import { NotFoundError } from "./apiClient/errors.js";
1112

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

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

242-
// Try to extract options from an IdempotencyKey created with idempotencyKeys.create()
243-
const attachedOptions =
244-
typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined;
244+
if (isCreatedKey || options?.scope === undefined) {
245+
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
246+
}
247+
}
245248

246-
const scope = attachedOptions?.scope ?? options?.scope ?? "run";
247-
const keyArray = Array.isArray(idempotencyKey)
248-
? idempotencyKey
249-
: [attachedOptions?.key ?? String(idempotencyKey)];
249+
const scope = options?.scope ?? "run";
250+
const keyArray = Array.isArray(idempotencyKey) ? idempotencyKey : [idempotencyKey];
250251

251252
// Build scope suffix based on scope type
252253
let scopeSuffix: string[] = [];
253254
switch (scope) {
254255
case "run": {
255256
const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id;
256257
if (!parentRunId) {
258+
// We can't derive a hash, but a 64-char key may already be one, so try it rather than fail
259+
if (is64CharKey) {
260+
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
261+
}
257262
throw new Error(
258263
"resetIdempotencyKey: parentRunId is required for 'run' scope when called outside a task context"
259264
);
@@ -265,6 +270,9 @@ export async function resetIdempotencyKey(
265270
const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id;
266271
const attemptNumber = options?.attemptNumber ?? taskContext?.ctx?.attempt.number;
267272
if (!parentRunId || attemptNumber === undefined) {
273+
if (is64CharKey) {
274+
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
275+
}
268276
throw new Error(
269277
"resetIdempotencyKey: parentRunId and attemptNumber are required for 'attempt' scope when called outside a task context"
270278
);
@@ -277,5 +285,21 @@ export async function resetIdempotencyKey(
277285
// Generate the hash using the same algorithm as createIdempotencyKey
278286
const hash = await generateIdempotencyKey(keyArray.concat(scopeSuffix));
279287

280-
return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
288+
if (!is64CharKey) {
289+
return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
290+
}
291+
292+
try {
293+
return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
294+
} catch (error) {
295+
if (!(error instanceof NotFoundError)) {
296+
throw error;
297+
}
298+
299+
try {
300+
return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
301+
} catch (fallbackError) {
302+
throw fallbackError instanceof NotFoundError ? error : fallbackError;
303+
}
304+
}
281305
}

0 commit comments

Comments
 (0)