Skip to content

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
mainfrom
fix/idempotency-reset-64-char-key
Open

fix(core): don't assume a 64-character idempotency key is pre-hashed on reset#4626
claude[bot] wants to merge 6 commits into
mainfrom
fix/idempotency-reset-64-char-key

Conversation

@claude

@claude claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Requested by Matt Aitken · Slack thread

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

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • 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.

@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dc081e5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/sdk Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/dashboard-agent Patch
@internal/cache Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@dc081e5

trigger.dev

npm i https://pkg.pr.new/trigger.dev@dc081e5

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@dc081e5

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@dc081e5

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@dc081e5

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@dc081e5

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@dc081e5

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@dc081e5

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@dc081e5

commit: dc081e5

@matt-aitken
matt-aitken marked this pull request as ready for review August 15, 2026 09:26
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

claude and others added 5 commits August 19, 2026 11:39
…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
matt-aitken force-pushed the fix/idempotency-reset-64-char-key branch from d38e1b8 to fa7634f Compare August 19, 2026 10:43
devin-ai-integration[bot]

This comment was marked as resolved.

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.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread packages/core/src/v3/idempotencyKeys.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants