fix(core): don't assume a 64-character idempotency key is pre-hashed on reset - #4626
Open
claude[bot] wants to merge 6 commits into
Open
fix(core): don't assume a 64-character idempotency key is pre-hashed on reset#4626claude[bot] wants to merge 6 commits into
claude[bot] wants to merge 6 commits into
Conversation
🦋 Changeset detectedLatest commit: dc081e5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 27 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/sdk
commit: |
matt-aitken
marked this pull request as ready for review
August 15, 2026 09:26
…on reset `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 a user key that is itself a 64-character digest had an explicitly passed `scope` silently discarded and was sent un-hashed, matching no run. A 64-character string is now only passed through when there is evidence it is already a hash: the idempotency key catalog recognises it (so it came from `idempotencyKeys.create()`), or no `scope` was passed and the length is the only signal available. An explicit `scope` is an explicit request to derive the hash, so it is always honoured. This keeps both existing behaviours intact: a key from `idempotencyKeys.create()` is still forwarded unchanged, and 64-character key material passed straight to `trigger()` and reset without a scope is still sent verbatim. `isIdempotencyKey` is deliberately untouched, since the trigger path is self-consistent and changing it would invalidate already-stored keys. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ived hash misses A 64-character string passed to reset() with an explicit scope is ambiguous: it may be raw key material to hash, or a key already produced by create(). The catalog can only tell the two apart in-process, and workers clear it at each run boundary, so resetting a created key from another run or process while passing a scope would double-hash it and match nothing. Send the derived hash first, then fall back to the value verbatim on a 404. Non-404s propagate immediately, and a double miss surfaces the derived attempt's error. Co-Authored-By: Claude <noreply@anthropic.com>
…lback Two problems with the 64-character reset fallback: The server answers 503, not 404, when Postgres matched nothing and it could not check the buffer, so a miss could arrive as a non-404 and the NotFoundError-only catch skipped the verbatim retry. The speculative request is a guess by construction, so any failure now falls through to the verbatim key. A double miss still surfaces the derived attempt's error, and a non-404 from the fallback surfaces instead. A pre-hashed key with "run" or "attempt" scope and no parentRunId threw before any request was made, which used to work. Send those verbatim when the key is already 64 characters; shorter material still throws, since there is nothing useful to send. Co-Authored-By: Claude <noreply@anthropic.com>
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.
matt-aitken
force-pushed
the
fix/idempotency-reset-64-char-key
branch
from
August 19, 2026 10:43
d38e1b8 to
fa7634f
Compare
A transient failure (503, connection error) of the verbatim attempt leaves that key's state unknown. Issuing the derived-hash reset anyway is a write against a key the caller may not have targeted, and it reports success while the verbatim run stays deduplicated. Now only a 404, a definitive miss, unlocks the fallback; any other error surfaces unchanged, exactly as previous versions behaved.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Matt Aitken · Slack thread
idempotencyKeys.reset()now honours an explicitly passedscopeeven when the key material happens to be 64 characters long.Before:
resetIdempotencyKeytreated 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) thescopeyou 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
scopeis 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:
idempotencyKeys.create()in this process), orscopewas passed, so there is nothing to derive a hash from, orscope: "run"outside a task context with noparentRunId).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 ascopefrom a cold process resolves in a single request, because the created key is itself the stored value.isIdempotencyKeyis 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?.scopefallbacks 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
Testing
Tests in
packages/core/src/v3/idempotencyKeys.test.tsdrive the realresetIdempotencyKeyagainst a local HTTP server and assert on the exact values that reach the wire, in order. Nothing is mocked. They cover:scopederives the global- and run-scoped hash once the verbatim key misses (fails without this change)idempotencyKeys.create()are forwarded unchanged: catalog hit, no scope, and scope with a cold catalog (the last now a single request)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 ascopesilently ignored the scope and the reset never found a matching run.Follow-ups (not in this PR)
docs/idempotency.mdxdescribes theidempotencyKeyparameter ofreset()as "the 64-character hash string" in one place while showing raw material plus{ scope: "global" }a few lines later. Worth reconciling.ctx.run.idempotencyKey, the run page and theidempotency_keyquery column all show the user-provided key. That is what leads people to send a value reset cannot match.