Skip to content

feat(fetcher): auto-mint Idempotency-Key on send endpoints#23

Open
sidharth0612 wants to merge 1 commit into
mainfrom
sid/auto-mint-idempotency-key
Open

feat(fetcher): auto-mint Idempotency-Key on send endpoints#23
sidharth0612 wants to merge 1 commit into
mainfrom
sid/auto-mint-idempotency-key

Conversation

@sidharth0612

@sidharth0612 sidharth0612 commented Jul 3, 2026

Copy link
Copy Markdown

Makes the SDK's send calls idempotent-by-default. The AgentMail API dedupes sends via an Idempotency-Key header (agentmail-api#522). The SDK auto-retries 408/429/5xx, and without a stable key each retry would be an un-deduped send — this defaults a key per logical call so retries can never duplicate an email.

Reworked: wrapper approach — no generated files pinned

This PR was previously a core-fetcher patch that required freezing Fetcher.ts in .fernignore (the likely reason it was closed — fair). It has been reworked from scratch on top of 0.5.14:

  • .fernignore untouched. src/core/ untouched. The entire change lives in src/wrapper/ + tests/unit/wrapper/ — the human-owned custom-code area this repo already uses for the custom WebsocketsClient. Fern regeneration flows freely through everything it owns.
  • Wrapped MessagesClient (send/reply/replyAll/forward) and DraftsClient (send) default the option 0.5.14 itself introduced: requestOptions.idempotencyKey ?? uuidv4(). A caller-supplied key always wins; a raw requestOptions.headers['Idempotency-Key'] also wins (it merges last in the generated client).
  • Wired via an inboxes getter override in src/wrapper/Client.ts, mirroring the existing websockets override pattern exactly.
  • Retry stability comes from the generated code itself: the generated methods merge idempotencyKey into the headers once, before core.fetcher's retry loop — so every internal retry of one call reuses the same key. Verified by a 503-then-200 test asserting identical keys on both attempts.

Why Fern can't generate this part

Fern's idempotency-headers feature (enabled in agentmail-docs#180, live in 0.5.14) generates the typed pass-throughrequestOptions.idempotencyKey → header. There is no generator feature for value generation ("mint a UUID when the caller omits one"): that's client behavior, not request shape, so it belongs in the wrapper. Long-term, auto-generation would be a reasonable upstream feature request to Fern; until then this is the sanctioned place for it.

Without a default, the SDK's own auto-retries are idempotent only for callers who explicitly pass idempotencyKey — everyone else keeps the double-send-on-retry hole. (Stripe's SDKs auto-generate idempotency keys on retryable requests for the same reason.)

Known non-covered path

client.fetch(...) (the raw passthrough escape hatch) bypasses the wrapper by design — it's documented raw access. .withRawResponse() is covered (it flows through the same public methods).

Tests / build

tests/unit/wrapper/idempotency.test.ts — 11 tests through the real public AgentMailClient with injected fetch: mint-when-absent (valid UUID4), caller idempotencyKey wins, raw header wins, identical key across a 503→200 retry, distinct keys per call, all five endpoints mint, non-send methods untouched. Full unit suite: 491/491. tsc cjs+esm clean. Biome clean on the new files (the 2 lazy-getter warnings match the identical pre-existing pattern on the websockets getter). No version bump.

🤖 Generated with Claude Code

@sidharth0612 sidharth0612 requested a review from duharry0915 July 6, 2026 20:09
@duharry0915 duharry0915 closed this Jul 6, 2026
@sidharth0612

Copy link
Copy Markdown
Author

Rebased onto the regenerated main (0.5.14). The regen's native idempotency support composes cleanly with this patch: the generated clients merge requestOptions.idempotencyKey into the request headers before core.fetcher, which is exactly where applyIdempotencyKey checks — so a caller-supplied typed key suppresses the mint, and the mint only fills the gap when no key was given. No conflicts (src/core/fetcher/ untouched by the regen); tsc cjs+esm clean; 488/488 unit tests pass.

@sidharth0612 sidharth0612 reopened this Jul 6, 2026
@sidharth0612 sidharth0612 force-pushed the sid/auto-mint-idempotency-key branch from 4fdef07 to 2be469a Compare July 6, 2026 22:15
duharry0915
duharry0915 previously approved these changes Jul 6, 2026
When the caller does not supply requestOptions.idempotencyKey, the SDK
now defaults it to a fresh UUID4 (once per logical call) for exactly the
five send endpoints: inboxes.messages.send / .reply / .replyAll /
.forward and inboxes.drafts.send. A caller-supplied idempotencyKey (or a
raw Idempotency-Key entry in requestOptions.headers) always wins, and no
other method is affected.

The default is applied at the wrapper method level, before the generated
client merges the key into the request headers and hands off to
core.fetcher — retries happen inside the fetcher reusing those headers,
so every internal retry of one call carries the identical key while
separate calls mint fresh keys.

Implemented entirely in src/wrapper (MessagesClient / DraftsClient /
InboxesClient subclasses plus an inboxes getter override on
AgentMailClient), the sanctioned custom-code area: Fern's generator only
emits typed pass-through for idempotency headers, and value generation
is client behavior. No generated files are modified or pinned in
.fernignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sidharth0612

Copy link
Copy Markdown
Author

@duharry0915 reworked per the (inferred) closure concern: the fetcher-layer patch is gone — Fetcher.ts and .fernignore are now byte-identical to main, and the auto-mint lives entirely in src/wrapper/ (same pattern as the custom WebsocketsClient), defaulting the native requestOptions.idempotencyKey that 0.5.14's regeneration introduced. No generated files pinned, nothing to reconcile on generator upgrades. 491/491 tests, tsc clean. PTAL.

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