Skip to content

Commit fa7634f

Browse files
committed
fix(core): try a 64-character reset key verbatim before the derived hash
The scoped reset of an ambiguous 64-character key sent the derived hash first and only fell back to the verbatim value on failure. That reversed the precedence every previous version had: a key stored verbatim (the only case that used to work) now cost an extra request, error messages named a hash the caller never passed, and when runs existed under both values the derived one was reset instead of the verbatim one. Sending the verbatim key first keeps every previously working call byte-identical (same single request, same target run, same error) and makes the derived hash a pure fallback, so the newly fixed create() flow still resolves on the second attempt. A created key reset with a scope now also resolves in one request, since the created key is itself the stored value.
1 parent 8744482 commit fa7634f

2 files changed

Lines changed: 31 additions & 33 deletions

File tree

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

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -122,22 +122,25 @@ describe("resetIdempotencyKey", () => {
122122
await new Promise<void>((resolve) => server.close(() => resolve()));
123123
});
124124

125-
it("hashes 64-character key material when an explicit scope is passed", async () => {
125+
it("derives the hash for 64-character key material with an explicit scope when the verbatim key misses", async () => {
126126
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });
127127

128-
// The reset can happen in a different process from the trigger
129128
resetIdempotencyKeyCatalog();
129+
existingKeys = new Set([created]);
130+
131+
await resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" });
130132

131-
expect(await resetAndCaptureKey("my-task", digestShapedKey, { scope: "global" })).toBe(created);
133+
expect(resetKeys).toEqual([digestShapedKey, created]);
132134
});
133135

134-
it("hashes 64-character key material for run scope when an explicit scope is passed", async () => {
136+
it("derives the run-scoped hash for 64-character key material when the verbatim key misses", async () => {
135137
const parentRunId = "run_abc123";
136138
const expected = await digestSHA256(`${digestShapedKey}-${parentRunId}`);
139+
existingKeys = new Set([expected]);
140+
141+
await resetIdempotencyKey("my-task", digestShapedKey, { scope: "run", parentRunId });
137142

138-
expect(
139-
await resetAndCaptureKey("my-task", digestShapedKey, { scope: "run", parentRunId })
140-
).toBe(expected);
143+
expect(resetKeys).toEqual([digestShapedKey, expected]);
141144
});
142145

143146
it("sends a key created with idempotencyKeys.create() unchanged while the catalog knows it", async () => {
@@ -158,16 +161,13 @@ describe("resetIdempotencyKey", () => {
158161
expect(await resetAndCaptureKey("my-task", created)).toBe(created);
159162
});
160163

161-
it("falls back to the verbatim key when a created key is reset with a scope and the catalog is cold", async () => {
164+
it("resolves a created key in one request when reset with a scope and the catalog is cold", async () => {
162165
const created = await createIdempotencyKey("my-key", { scope: "global" });
163166

164167
resetIdempotencyKeyCatalog();
165168
existingKeys = new Set([created]);
166169

167-
await resetIdempotencyKey("my-task", created, { scope: "global" });
168-
169-
// The derived hash misses, so the already-hashed key is retried verbatim
170-
expect(resetKeys).toEqual([await digestSHA256(created), created]);
170+
expect(await resetAndCaptureKey("my-task", created, { scope: "global" })).toBe(created);
171171
});
172172

173173
it("sends a 64-character key unchanged when no scope is passed", async () => {
@@ -182,38 +182,37 @@ describe("resetIdempotencyKey", () => {
182182
expect(await resetAndCaptureKey("my-task", digestShapedKey)).toBe(digestShapedKey);
183183
});
184184

185-
it("does not fall back when the derived hash for 64-character material matches", async () => {
185+
it("resets the verbatim run when runs exist under both the verbatim key and the derived hash", async () => {
186186
const created = await createIdempotencyKey(digestShapedKey, { scope: "global" });
187187

188188
resetIdempotencyKeyCatalog();
189-
existingKeys = new Set([created]);
189+
existingKeys = new Set([digestShapedKey, created]);
190190

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

193-
expect(resetKeys).toEqual([created]);
193+
expect(resetKeys).toEqual([digestShapedKey]);
194194
});
195195

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

200199
resetIdempotencyKeyCatalog();
201-
statusByKey.set(await digestSHA256(created), 503);
200+
statusByKey.set(digestShapedKey, 503);
202201
existingKeys = new Set([created]);
203202

204203
await resetIdempotencyKey(
205204
"my-task",
206-
created,
205+
digestShapedKey,
207206
{ scope: "global" },
208207
{ retry: { maxAttempts: 1 } }
209208
);
210209

211-
expect(resetKeys).toEqual([await digestSHA256(created), created]);
210+
expect(resetKeys).toEqual([digestShapedKey, created]);
212211
});
213212

214-
it("surfaces the derived key's error when it fails with a 503 and the fallback finds nothing", async () => {
213+
it("surfaces the verbatim attempt's error when it fails with a 503 and the fallback finds nothing", async () => {
215214
const derived = await digestSHA256(digestShapedKey);
216-
statusByKey.set(derived, 503);
215+
statusByKey.set(digestShapedKey, 503);
217216
existingKeys = new Set();
218217

219218
await expect(
@@ -225,13 +224,13 @@ describe("resetIdempotencyKey", () => {
225224
)
226225
).rejects.toMatchObject({ status: 503 });
227226

228-
expect(resetKeys).toEqual([derived, digestShapedKey]);
227+
expect(resetKeys).toEqual([digestShapedKey, derived]);
229228
});
230229

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

236235
await expect(
237236
resetIdempotencyKey(
@@ -242,18 +241,18 @@ describe("resetIdempotencyKey", () => {
242241
)
243242
).rejects.toMatchObject({ status: 503 });
244243

245-
expect(resetKeys).toEqual([derived, digestShapedKey]);
244+
expect(resetKeys).toEqual([digestShapedKey, derived]);
246245
});
247246

248-
it("surfaces the derived key's error when both attempts 404", async () => {
247+
it("surfaces the verbatim key's error when both attempts 404", async () => {
249248
const derived = await digestSHA256(digestShapedKey);
250249
existingKeys = new Set();
251250

252251
await expect(
253252
resetIdempotencyKey("my-task", digestShapedKey, { scope: "global" })
254-
).rejects.toThrow(notFoundMessage(derived));
253+
).rejects.toThrow(notFoundMessage(digestShapedKey));
255254

256-
expect(resetKeys).toEqual([derived, digestShapedKey]);
255+
expect(resetKeys).toEqual([digestShapedKey, derived]);
257256
});
258257

259258
it("hashes key material that is not 64 characters", async () => {

packages/core/src/v3/idempotencyKeys.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,11 @@ export async function resetIdempotencyKey(
289289
return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
290290
}
291291

292-
// Hashing a 64-char key is a guess, so if it fails at all, still try the key verbatim
293292
try {
294-
return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
293+
return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
295294
} catch (error) {
296295
try {
297-
return await client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
296+
return await client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
298297
} catch (fallbackError) {
299298
throw fallbackError instanceof NotFoundError ? error : fallbackError;
300299
}

0 commit comments

Comments
 (0)