Skip to content

feat(typescript): read idempotency-key generation config from the IR#17007

Open
devin-ai-integration[bot] wants to merge 10 commits into
mainfrom
devin/1783694510-ts-idempotency-key
Open

feat(typescript): read idempotency-key generation config from the IR#17007
devin-ai-integration[bot] wants to merge 10 commits into
mainfrom
devin/1783694510-ts-idempotency-key

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Description

Switches the TypeScript SDK generator from its own autoGenerateIdempotencyKey config flag to reading the centralized sdkConfig.idempotencyKeyGeneration block from the IR (added + threaded by the CLI in #17018, published as @fern-fern/ir-sdk@67.11.0).

The behavior is unchanged from a caller's perspective: when idempotency-key generation is enabled, the generated SDK attaches a UUIDv4 idempotency-key header on eligible HTTP methods, unless the caller supplies one. The difference is where the config lives: enablement, header name, and the eligible-method set now come from the IR instead of being defined per-generator and hardcoded to POST/PUT. This is the generator half of the IR centralization (PR2); #17018 was PR1.

// before: per-generator flag + hardcoded methods
autoGenerateIdempotencyKey && (method === "POST" || method === "PUT")

// after: read from IR
const gen = ir.sdkConfig.idempotencyKeyGeneration;
const headerName = gen?.headerName ?? "Idempotency-Key";
const enabled = gen != null && gen.methods.includes(endpoint.method);

Changes Made

  • generateHeaders.ts: read intermediateRepresentation.sdkConfig.idempotencyKeyGeneration — header name from headerName, eligibility from methods.includes(endpoint.method) — instead of the per-generator flag + hardcoded POST/PUT.
  • SdkGenerator.ts: gate emitting the core/idempotency.ts asIs helper on IR-field presence (idempotencyKeyGeneration != null) via AsIsManager.
  • Removed the autoGenerateIdempotencyKey config plumbing: dropped from SdkCustomConfig/SdkClientClassGenerator/GeneratedSdkClientClassImpl and from the v2 TypescriptCustomConfigSchema (dead, unconsumed). The CLI now owns the auto-generate-idempotency-key config key and threads it into the IR.
  • Bumped @fern-fern/ir-sdk to 67.11.0 (contains idempotencyKeyGeneration with methods) and reconciled pnpm-lock.yaml.
  • Seed: idempotency-headers fixture now drives the feature via the CLI key (auto-generate-idempotency-key: true); regenerated output proves the caller-wins fallback (requestOptions?.idempotencyKey ?? generateIdempotencyKey()) on the declared POST endpoint.
  • fix(seed): made the CLI-only-key threaders (getIdempotencyKeyGenerationFromGeneratorConfig) fall back to the resolved generatorInvocation.config when raw is absent, so the seed harness's synthetic invocations thread the IR field. Production (where raw is always populated) is unchanged. Added unit tests.
  • Added an unreleased feat changelog entry.
  • Updated README.md generator (if applicable) — N/A

Testing

  • Unit tests added/updated — @fern-api/api-workspace-commons 186 passed (incl. 4 new idempotency-threading cases); TS generator unit suite 570 passed.
  • Manual testing completed — pnpm seed test --generator ts-sdk --fixture idempotency-headers --local → 2/2 pass. Flag-on output injects Idempotency-Key: requestOptions?.idempotencyKey ?? generateIdempotencyKey() on the POST endpoint and emits core/idempotency.ts; flag-off (no-custom-config) output has no injection.

Reference implementation for TypeScript; the other 6 generators (Python, Java, Go, C#, PHP, Ruby) follow the same IR-reading pattern in their respective PRs. Held for Twilio design-doc sign-off before merge.

Link to Devin session: https://app.devin.ai/sessions/c3ddfc2bb3b04f9a9e2977e756158dee
Requested by: @cadesark


Open in Devin Review

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

The PR adds an opt-in autoGenerateIdempotencyKey flag that injects a UUIDv4 Idempotency-Key header on POST/PUT requests. Threading through the pipeline is consistent and well-tested. Main concerns: the fallback UUID generator has a bias bug that produces invalid version-4 UUIDs, and the seed CI workflow has a trailing-whitespace/formatting nit.

  • 🟡 1 warning(s)
  • 🔵 3 suggestion(s)

Comment on lines +14 to +15
const random = (Math.random() * 16) | 0;
const value = character === "x" ? random : (random & 0x3) | 0x8;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

(Math.random() * 16) | 0 can yield 16 when Math.random() returns exactly the value that rounds up... actually no — Math.random() is [0,1) so *16 is [0,16) and | 0 truncates to 0..15, which is fine. The real issue: for the y position you compute (random & 0x3) | 0x8 giving 8..b (correct), and x gives 0..f (correct). This is standard and OK. Disregard — no bug here.

(Leaving this as a note: the implementation is correct; crypto.randomUUID() is preferred and the fallback is a valid v4 UUID.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it have the same guarantees as crypto.randomUUID?

Comment on lines +93 to +104
if (
generatedSdkClientClass.getAutoGenerateIdempotencyKey() &&
(endpoint.method === FernIr.HttpMethod.Post || endpoint.method === FernIr.HttpMethod.Put)
) {
context.importsManager.addImportFromRoot("core/idempotency", {
namedImports: ["generateIdempotencyKey"]
});
elements.push({
header: "Idempotency-Key",
value: ts.factory.createCallExpression(ts.factory.createIdentifier("generateIdempotencyKey"), undefined, [])
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

The auto-generated Idempotency-Key is pushed before getOverridableRootHeaders and additionalHeaders, but the PR description claims a user-supplied Idempotency-Key in requestOptions?.headers still wins. Confirm that requestOptions?.headers is merged after this elements array in the final mergeOnlyDefinedHeaders/merge order — otherwise the "user wins" guarantee doesn't hold. Worth an assertion in the test that a user-supplied key overrides the generated one, rather than only checking the generated call is present.


- name: Set up node
uses: actions/setup-node@v6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

Trailing whitespace on this line. Generated output, so fix in the generator template if it's the source.

uses: actions/checkout@v6

- name: Set up node
uses: actions/setup-node@v6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

setup-node is used without a node-version — CI will pick whatever the runner defaults to, which drifts over time. Not blocking, but pinning avoids surprise breakages. (Again, fix at the template level since this is generated.)

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +93 to +104
if (
generatedSdkClientClass.getAutoGenerateIdempotencyKey() &&
(endpoint.method === FernIr.HttpMethod.Post || endpoint.method === FernIr.HttpMethod.Put)
) {
context.importsManager.addImportFromRoot("core/idempotency", {
namedImports: ["generateIdempotencyKey"]
});
elements.push({
header: "Idempotency-Key",
value: ts.factory.createCallExpression(ts.factory.createIdentifier("generateIdempotencyKey"), undefined, [])
});
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Auto-generated idempotency key silently overwrites user-provided idempotency key on endpoints that already define one

The auto-generated header is appended after the API-defined idempotency header (elements.push at generators/typescript/sdk/client-class-generator/src/endpoints/utils/generateHeaders.ts:100-103) without checking for duplicates, so the user-supplied value from the typed request options parameter is silently discarded.

Impact: On idempotent POST/PUT endpoints whose API defines an Idempotency-Key header, the SDK ignores the caller's explicit key and always sends a fresh UUID, breaking retry semantics.

Duplicate-key mechanism in the generated object literal

When endpoint.idempotent is true, the API-defined idempotency headers are pushed to the elements array at lines 84-91 (e.g., "Idempotency-Key": requestOptions?.idempotencyKey). Then, when autoGenerateIdempotencyKey is also true and the method is POST or PUT, a second entry with the same key "Idempotency-Key" is pushed at lines 100-103 ("Idempotency-Key": generateIdempotencyKey()).

Both entries are mapped into a single JS object literal at lines 112-116. In JavaScript, duplicate keys in an object literal resolve to the last value, so the auto-generated UUID always wins. The mergeOnlyDefinedHeaders wrapper (generators/typescript/asIs/core/headers.ts:20-34) also uses last-write-wins semantics.

While the user can still override via the generic requestOptions.headers bag (which comes later in mergeHeaders), the typed idempotencyKey property on the idempotent request options interface is silently ignored — defeating the purpose of the typed API.

A fix would be to skip the auto-generation when the endpoint already has an idempotency header with wire value Idempotency-Key, e.g.:

const alreadyHasIdempotencyKey = endpoint.idempotent && idempotencyHeaders.some(h => getWireValue(h.name).toLowerCase() === "idempotency-key");
if (generatedSdkClientClass.getAutoGenerateIdempotencyKey() && !alreadyHasIdempotencyKey && ...) {
Suggested change
if (
generatedSdkClientClass.getAutoGenerateIdempotencyKey() &&
(endpoint.method === FernIr.HttpMethod.Post || endpoint.method === FernIr.HttpMethod.Put)
) {
context.importsManager.addImportFromRoot("core/idempotency", {
namedImports: ["generateIdempotencyKey"]
});
elements.push({
header: "Idempotency-Key",
value: ts.factory.createCallExpression(ts.factory.createIdentifier("generateIdempotencyKey"), undefined, [])
});
}
const alreadyHasIdempotencyKeyHeader =
endpoint.idempotent &&
idempotencyHeaders.some((h) => getWireValue(h.name).toLowerCase() === "idempotency-key");
if (
generatedSdkClientClass.getAutoGenerateIdempotencyKey() &&
!alreadyHasIdempotencyKeyHeader &&
(endpoint.method === FernIr.HttpMethod.Post || endpoint.method === FernIr.HttpMethod.Put)
) {
context.importsManager.addImportFromRoot("core/idempotency", {
namedImports: ["generateIdempotencyKey"]
});
elements.push({
header: "Idempotency-Key",
value: ts.factory.createCallExpression(ts.factory.createIdentifier("generateIdempotencyKey"), undefined, [])
});
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the mechanism — confirmed: when endpoint.idempotent is true and a declared idempotency header has wire value Idempotency-Key, the auto-generated entry is pushed after it into the same object literal, so last-write-wins makes the fresh UUID win over the typed idempotencyKey request option (the generic requestOptions.headers bag can still override, since it comes later in mergeHeaders).

Whether that's a defect depends on intended precedence, which was an explicit product decision for this flag: auto-generation is meant to be a blind, method-based injection on every POST/PUT that supersedes any manual key. Under that intent the current behavior is correct, not a bug. Your alternative — respect the declared/typed key and only auto-generate when the endpoint doesn't already expose one — is the more idiomatic reading and preserves retry semantics for declared-idempotent endpoints, but it also means a declared-idempotent POST/PUT sends no key when the caller omits the typed option (the declared header is optional), which partially defeats "auto-generate on every POST/PUT".

Confirming the desired precedence with the requester before changing behavior. If we do switch, I'll gate on endpoint.idempotent && idempotencyHeaders.some(h => getWireValue(h.name).toLowerCase() === "idempotency-key") as you suggest, and add a fixture variant covering an idempotent endpoint to lock the chosen semantics in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in df76380 with the runtime-fallback approach (requester's call): the caller's key wins when present, otherwise we auto-generate.

  • When an endpoint declares an Idempotency-Key idempotency header and the flag is on, the value is now wrapped as requestOptions?.idempotencyKey ?? generateIdempotencyKey() instead of pushing a duplicate entry — so the typed option is respected and the auto-gen UUID is only the fallback.
  • The standalone auto-gen entry is only emitted when the endpoint doesn't already expose a declared Idempotency-Key header (no more duplicate keys).

Locked in with a new idempotency-headers seed fixture (the API declares Idempotency-Key):

  • no-custom-config: "Idempotency-Key": requestOptions?.idempotencyKey,
  • auto-generate-idempotency-key: "Idempotency-Key": requestOptions?.idempotencyKey ?? generateIdempotencyKey(),

Plus a unit test asserting the fallback wrapping and that the key isn't duplicated. It's pure ?? (no ??/&& mix), so no TS5076.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-13T05:08:13Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
ts-sdk square 127s (n=5) 128s (n=5) 102s -25s (-19.7%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-13T05:08:13Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-13 22:23 UTC

@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1783694510-ts-idempotency-key branch from df76380 to 9b02be3 Compare July 10, 2026 20:34
@devin-ai-integration devin-ai-integration Bot changed the title feat(typescript): auto-generate idempotency key for POST/PUT feat(typescript): read idempotency-key generation config from the IR Jul 10, 2026
devin-ai-integration Bot and others added 6 commits July 10, 2026 20:36
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…dempotency resolver conflict in favor of main (global api.settings support)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Comment on lines +13 to +17
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (character) => {
const random = (Math.random() * 16) | 0;
const value = character === "x" ? random : (random & 0x3) | 0x8;
return value.toString(16);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this safe enough?

const _headers: core.Fetcher.Args["headers"] = mergeHeaders(
_authRequest.headers,
this._options?.headers,
mergeOnlyDefinedHeaders({ "Idempotency-Key": generateIdempotencyKey() }),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to specify the "Idempotency-Key" string in every endpoint method?
Can we have a single reusable method if the idempotency key is the same everywhere? have it return an object and you merge it with the other headers?
Reduce duplicate code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — refactored so the "Idempotency-Key" string and the key generation live in one place. Added a single reusable helper in core/idempotency.ts:

export function getIdempotencyHeaders(): Record<string, string> {
    return { "Idempotency-Key": generateIdempotencyKey() };
}

Endpoints now merge its result into the header chain instead of repeating mergeOnlyDefinedHeaders({ "Idempotency-Key": generateIdempotencyKey() }) at every call site:

mergeHeaders(authHeaders, this._options?.headers, getIdempotencyHeaders(), declaredHeaders, requestOptions?.headers)

When a custom header name is configured (via the IR), it's substituted into the copied core/idempotency.ts so the literal still lives in a single location. Merge order is unchanged, so a caller-supplied key still wins. Endpoints that declare an idempotency header keep the existing requestOptions?.idempotencyKey ?? generateIdempotencyKey() behavior.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Yes — safe enough for idempotency keys. Idempotency keys need uniqueness, not cryptographic unpredictability (unlike a session token or nonce, nothing security-sensitive keys off their value). We already prefer the platform's crypto.randomUUID() whenever it exists (Node 19+, modern browsers, Deno, Bun); the Math.random() path is only a fallback for older runtimes and still produces a well-formed v4 UUID with ~122 bits of randomness, so collision risk across a client's requests is negligible.

If we'd rather not carry the fallback at all, I'm happy to drop it and require a runtime with crypto.randomUUID() (or crypto.getRandomValues() for a stronger fill) — let me know your preference. Separately, addressing your dedup comment below by moving the header name + generation into a single reusable helper so it isn't re-emitted per endpoint.

…potencyHeaders helper

Address PR review: instead of re-emitting the Idempotency-Key magic string and
generateIdempotencyKey() call at every eligible endpoint, endpoints now merge in a single
shared core/idempotency.ts helper. The header name (default or IR-configured) lives in one
place; AsIsManager substitutes a custom header name into the copied helper at generation time.
Declared-idempotency-header endpoints keep the existing requestOptions.idempotencyKey ?? generateIdempotencyKey() path.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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.

1 participant