From 674807dda0e419860f689f4d90472c5fd3aedce6 Mon Sep 17 00:00:00 2001 From: Sidharth Reddy Date: Mon, 6 Jul 2026 15:42:58 -0700 Subject: [PATCH] feat(wrapper): auto-mint Idempotency-Key on send endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/wrapper/Client.ts | 11 ++ src/wrapper/idempotency.ts | 129 ++++++++++++++++++++ tests/unit/wrapper/idempotency.test.ts | 160 +++++++++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 src/wrapper/idempotency.ts create mode 100644 tests/unit/wrapper/idempotency.test.ts diff --git a/src/wrapper/Client.ts b/src/wrapper/Client.ts index c00aec39..892a0706 100644 --- a/src/wrapper/Client.ts +++ b/src/wrapper/Client.ts @@ -7,6 +7,7 @@ import { AgentMailClient as FernAgentMailClient } from "../Client.js"; import { type GetPaymentCredentials, WebsocketsClient } from "./WebsocketsClient.js"; import { getPaymentCredentials as getX402Credentials } from "./x402.js"; +import { InboxesClient } from "./idempotency.js"; import { type MppxClient, getPaymentCredentials as getMppCredentials } from "./mppx.js"; type SharedOptions = Omit; @@ -23,12 +24,22 @@ export declare namespace AgentMailClient { export class AgentMailClient extends FernAgentMailClient { protected declare _websockets: WebsocketsClient | undefined; + protected declare _inboxes: InboxesClient | undefined; private readonly _getPaymentCredentials: GetPaymentCredentials | undefined; public override get websockets(): WebsocketsClient { return (this._websockets ??= new WebsocketsClient(this._options, this._getPaymentCredentials)); } + /** + * Returns an InboxesClient whose `messages` / `drafts` clients auto-mint a + * fresh UUID4 `Idempotency-Key` for send / reply / replyAll / forward when + * the caller does not supply one (see src/wrapper/idempotency.ts). + */ + public override get inboxes(): InboxesClient { + return (this._inboxes ??= new InboxesClient(this._options)); + } + constructor(options: AgentMailClient.Options = {}) { if (options.x402) { const { x402, ...rest } = options; diff --git a/src/wrapper/idempotency.ts b/src/wrapper/idempotency.ts new file mode 100644 index 00000000..3f983a92 --- /dev/null +++ b/src/wrapper/idempotency.ts @@ -0,0 +1,129 @@ +/** + * Auto-minting of `Idempotency-Key` for the send endpoints. + * + * This lives in src/wrapper (the sanctioned custom-code area) rather than in + * the generated code or core fetcher: Fern's generator can only emit a typed + * pass-through for idempotency headers (`requestOptions.idempotencyKey`); + * generating a default VALUE is client behavior. Implementing it here as + * subclass overrides means no generated files are pinned in .fernignore. + * + * The generated `__send`-style methods merge `requestOptions.idempotencyKey` + * into the request headers ONCE, before calling `core.fetcher`, and retries + * happen INSIDE the fetcher reusing those headers — so defaulting the key at + * the method level is automatically retry-stable: every internal retry of one + * logical call carries the SAME key, while separate calls get fresh keys. + */ +import type * as AgentMail from "../api/index.js"; +import { InboxesClient as FernInboxesClient } from "../api/resources/inboxes/client/Client.js"; +import { DraftsClient as FernDraftsClient } from "../api/resources/inboxes/resources/drafts/client/Client.js"; +import { MessagesClient as FernMessagesClient } from "../api/resources/inboxes/resources/messages/client/Client.js"; +import type * as core from "../core/index.js"; + +/** + * Generate a RFC 4122 version 4 UUID using the environment-agnostic Web Crypto + * API that this SDK already targets (available in Node 19+, browsers, Deno, Bun, + * Cloudflare Workers, etc.). Falls back to a `getRandomValues`-based + * implementation when `crypto.randomUUID` is unavailable, and finally to + * `Math.random` as a last resort so the SDK never throws in an exotic runtime. + * + * No new npm dependencies are introduced. + */ +export function uuidv4(): string { + const cryptoObj: Crypto | undefined = + typeof globalThis !== "undefined" ? (globalThis.crypto as Crypto | undefined) : undefined; + + if (cryptoObj != null && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (cryptoObj != null && typeof cryptoObj.getRandomValues === "function") { + cryptoObj.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + + // Per RFC 4122 §4.4: set the version (4) and variant (10xx) bits. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * Default `idempotencyKey` to a fresh UUID4 when the caller has not supplied + * one. Written as `requestOptions?.idempotencyKey ?? uuidv4()` (NOT a + * spread-over-default) so an explicit `idempotencyKey: undefined` still gets a + * minted key, while any caller-supplied value always wins. A caller-supplied + * raw `requestOptions.headers["Idempotency-Key"]` also wins, because the + * generated header merge applies `requestOptions.headers` last. + */ +function withDefaultIdempotencyKey( + requestOptions: T, +): NonNullable { + return { ...requestOptions, idempotencyKey: requestOptions?.idempotencyKey ?? uuidv4() } as NonNullable; +} + +export class MessagesClient extends FernMessagesClient { + public override send( + inbox_id: AgentMail.inboxes.InboxId, + request: AgentMail.SendMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.send(inbox_id, request, withDefaultIdempotencyKey(requestOptions)); + } + + public override reply( + inbox_id: AgentMail.inboxes.InboxId, + message_id: AgentMail.MessageId, + request: AgentMail.ReplyToMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.reply(inbox_id, message_id, request, withDefaultIdempotencyKey(requestOptions)); + } + + public override replyAll( + inbox_id: AgentMail.inboxes.InboxId, + message_id: AgentMail.MessageId, + request: AgentMail.ReplyAllMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.replyAll(inbox_id, message_id, request, withDefaultIdempotencyKey(requestOptions)); + } + + public override forward( + inbox_id: AgentMail.inboxes.InboxId, + message_id: AgentMail.MessageId, + request: AgentMail.SendMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.forward(inbox_id, message_id, request, withDefaultIdempotencyKey(requestOptions)); + } +} + +export class DraftsClient extends FernDraftsClient { + public override send( + inbox_id: AgentMail.inboxes.InboxId, + draft_id: AgentMail.DraftId, + request: AgentMail.UpdateMessageRequest, + requestOptions?: FernDraftsClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.send(inbox_id, draft_id, request, withDefaultIdempotencyKey(requestOptions)); + } +} + +export class InboxesClient extends FernInboxesClient { + protected declare _messages: MessagesClient | undefined; + protected declare _drafts: DraftsClient | undefined; + + public override get messages(): MessagesClient { + return (this._messages ??= new MessagesClient(this._options)); + } + + public override get drafts(): DraftsClient { + return (this._drafts ??= new DraftsClient(this._options)); + } +} diff --git a/tests/unit/wrapper/idempotency.test.ts b/tests/unit/wrapper/idempotency.test.ts new file mode 100644 index 00000000..da0bb88a --- /dev/null +++ b/tests/unit/wrapper/idempotency.test.ts @@ -0,0 +1,160 @@ +import { AgentMailClient } from "../../../src/wrapper/Client"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function sendMessageResponse(): Response { + return jsonResponse({ message_id: "msg_123", thread_id: "thread_123" }); +} + +/** Pull the Idempotency-Key out of the RequestInit a mocked fetch was called with. */ +function idempotencyKeyFromCall(call: unknown[]): string | null { + const init = call[1] as RequestInit; + return new Headers(init.headers).get("Idempotency-Key"); +} + +function makeClient(fetchMock: typeof fetch): AgentMailClient { + return new AgentMailClient({ apiKey: "am_us_test123", fetch: fetchMock }); +} + +describe("Idempotency-Key auto-minting (wrapper)", () => { + it("mints a valid UUID4 Idempotency-Key on messages.send with none supplied", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toMatch(UUID_V4); + }); + + it("uses a caller-supplied requestOptions.idempotencyKey verbatim", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }, { idempotencyKey: "caller-key" }); + + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toBe("caller-key"); + }); + + it("mints when the caller passes an explicit idempotencyKey: undefined", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }, { idempotencyKey: undefined }); + + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toMatch(UUID_V4); + }); + + it("lets a caller-supplied raw Idempotency-Key header win over the minted key", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send( + "inbox_id", + { text: "hi" }, + { headers: { "Idempotency-Key": "raw-header-key" } }, + ); + + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toBe("raw-header-key"); + }); + + it("carries the IDENTICAL minted key across internal retries (503 then 200)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("busy", { status: 503, headers: { "Retry-After": "1" } })) + .mockResolvedValueOnce(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }, { maxRetries: 1 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const first = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + const second = idempotencyKeyFromCall(fetchMock.mock.calls[1]); + expect(first).toMatch(UUID_V4); + expect(second).toBe(first); + }, 15000); + + it("mints DIFFERENT keys for two separate calls", async () => { + const fetchMock = vi.fn(async () => sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "one" }); + await client.inboxes.messages.send("inbox_id", { text: "two" }); + + const first = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + const second = idempotencyKeyFromCall(fetchMock.mock.calls[1]); + expect(first).toMatch(UUID_V4); + expect(second).toMatch(UUID_V4); + expect(second).not.toBe(first); + }); + + it("mints on messages.reply", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.reply("inbox_id", "message_id", { text: "hi" }); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("mints on messages.replyAll", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.replyAll("inbox_id", "message_id", { text: "hi" }); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("mints on messages.forward", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.forward("inbox_id", "message_id", { text: "hi" }); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("mints on drafts.send", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.drafts.send("inbox_id", "draft_id", {}); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("does NOT attach an Idempotency-Key to non-send methods", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ count: 0, messages: [] })) + .mockResolvedValueOnce( + jsonResponse({ + inbox_id: "inbox_id", + organization_id: "org_id", + display_name: "Test", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }), + ); + const client = makeClient(fetchMock); + + await client.inboxes.messages.list("inbox_id"); + await client.inboxes.create({ username: "test" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toBeNull(); + expect(idempotencyKeyFromCall(fetchMock.mock.calls[1])).toBeNull(); + }); +});