feat(ruby): IR-driven idempotency key generation#17008
feat(ruby): IR-driven idempotency key generation#17008devin-ai-integration[bot] wants to merge 7 commits into
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
AI Review Summary
PR ports auto-generated idempotency key support to the Ruby v2 generator. The core logic is sound and correctly gated behind an opt-in flag. One correctness concern: the fallback expression references request_options[:idempotency_key], which may not match how request options are actually keyed/threaded in generated methods.
- 🟡 1 warning(s)
- 🔵 1 suggestion(s)
| this.context.ir.idempotencyHeaders.some( | ||
| (header) => getWireValue(header.name).toLowerCase() === IDEMPOTENCY_KEY_HEADER.toLowerCase() | ||
| ); | ||
| return declaresIdempotencyKey ? `request_options[:idempotency_key] || SecureRandom.uuid` : `SecureRandom.uuid`; |
There was a problem hiding this comment.
🟡 warning
request_options[:idempotency_key] assumes request_options is in scope as a local variable at the point where the header bag is built, and that the caller's idempotency value is keyed under :idempotency_key. Confirm both: (1) request_options is always the accessible local at this injection site (the generated create method), and (2) the declared idempotency header actually surfaces via request_options[:idempotency_key] rather than through the normal header-parameter code block. If the header is instead emitted as a named method param, this fallback silently ignores the caller's value. The committed fixture only shows the flag-on create output — worth double-checking the standalone-vs-fallback branch is exercised.
There was a problem hiding this comment.
Verified both points against the generated output — not a bug:
-
request_optionsis always in scope. Every generated endpoint method has the signaturedef <name>(request_options: {}, **params), and the injection site sits inside that method body. It's the same local used forrequest_options[:base_url]elsewhere in the same method. -
:idempotency_keyis a valid caller-wins key. In ruby-v2,request_optionsis an open hash (default{}), read via[]/dig— there's no typedRequestOptionsstruct restricting keys (unlike the TSIdempotentRequestOptions.idempotencyKey). Soclient.payment.create(request_options: { idempotency_key: "abc" }, ...)sets the header and the generated UUID is only the fallback. This is the idiomatic Ruby analog of the TSrequestOptions?.idempotencyKey ?? generateIdempotencyKey().
Additionally, the generic override path still works and is unchanged: the header bag flows into sdk_headers and merge_additional_headers merges request_options[:additional_headers] on top (Idempotency-Key is not in protected_keys), so a caller can also override via additional_headers.
On the standalone-vs-fallback branch: the shared idempotency-headers test-definition only declares an idempotent POST (create) plus a DELETE, so the committed fixture exercises the declared/fallback branch and the never-inject branch (DELETE). The standalone SecureRandom.uuid branch (plain POST/PUT with no declared Idempotency-Key) is reached by the same code path; I intentionally didn't add a plain-POST endpoint to the shared definition because that would force regeneration across every generator's fixtures — the TS reference PR #17007 didn't add one either.
| this.context.ir.idempotencyHeaders.some( | ||
| (header) => getWireValue(header.name).toLowerCase() === IDEMPOTENCY_KEY_HEADER.toLowerCase() | ||
| ); | ||
| return declaresIdempotencyKey ? `request_options[:idempotency_key] || SecureRandom.uuid` : `SecureRandom.uuid`; |
There was a problem hiding this comment.
🔵 suggestion
Template literals with no interpolation — plain string literals are cleaner:
| return declaresIdempotencyKey ? `request_options[:idempotency_key] || SecureRandom.uuid` : `SecureRandom.uuid`; | |
| return declaresIdempotencyKey ? "request_options[:idempotency_key] || SecureRandom.uuid" : "SecureRandom.uuid"; |
There was a problem hiding this comment.
Done — switched to plain string literals in fb27aae. Generated output is byte-identical (same string), so no fixture change.
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
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 |
fb27aae to
0b89ce8
Compare
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>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
0b89ce8 to
79cee78
Compare
Docs Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on
Docs generation runs |
b4bbefb to
79cee78
Compare
There was a problem hiding this comment.
AI Review Summary
The PR makes idempotency-key generation IR-driven in the Ruby generator and fixes the seed-harness config fallback. Core logic looks sound. One concern about the generated header-injection ordering relative to additional_headers merge behavior, and a minor duplicate-key case-sensitivity detail worth confirming.
- 🔵 1 suggestion(s)
| // If a declared endpoint header already carries the configured wire name it is | ||
| // emitted by the header code block; avoid a duplicate entry. | ||
| const hasEndpointIdempotencyKeyHeader = endpoint.headers.some( | ||
| (header) => getWireValue(header.name).toLowerCase() === headerName.toLowerCase() |
There was a problem hiding this comment.
🔵 suggestion
Case-insensitive matching against endpoint headers is good, but note that when you do inject (headerName from config) you emit the exact configured casing as the hash key. If the underlying request bag later merges additional_headers with case-insensitive dedup (as merge_additional_headers does), a caller passing a differently-cased variant would be filtered — which is probably the intent, just confirm the two casings line up. No change required if that's expected.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… idempotency resolver conflict in favor of main (global api.settings support) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
3 similar comments
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
|
Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it. |
…in a core helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Description
Makes idempotency-key generation in the modern Ruby generator (
generators/ruby-v2) IR-driven, mirroring the TS reference PR #17007.Important
Depends on #17025 (
chore(ruby): upgrade Ruby SDK generator to IR v67). This PR is based on that branch and its base is set to it.sdkConfig.idempotencyKeyGenerationis only fed to the generator once ruby-v2 consumes IR v67, so this goes CI-green once #17025 merges. The diff shown here is idempotency-only.Instead of a per-generator config flag with hardcoded
POST/PUT, the generator now readsir.sdkConfig.idempotencyKeyGeneration(optional block withheaderName: stringandmethods: HttpMethod[]). Field present = enabled; absent = disabled.headerNamesupplies the header;methodsgates which HTTP methods get it (no hardcoding).Runtime behavior is preserved: the caller-supplied key wins, otherwise a UUIDv4 is generated (
SecureRandom.uuid). With idempotency disabled, output is byte-identical tomain.Changes Made
HttpEndpointGenerator.ts— newgetAutoGeneratedIdempotencyKey({ endpoint, requestType })readscontext.ir.sdkConfig.idempotencyKeyGeneration, gates on the configuredmethodsand on request type (json/multipartform), and skips injection if the endpoint already declares the header. Emits caller-wins fallbackrequest_options[:idempotency_key] || SecureRandom.uuidinto the existing header bag (or a new one). Removed the old config-flag + hardcoded-method plumbing.packages/commons/api-workspace-commons/src/checkVersionExists.ts— brought in the shared seed-harness fix verbatim from the TS reference:getGeneratorConfigObject()falls back togeneratorInvocation.configwhenraw.configis absent, used bygetIdempotencyKeyGenerationFromGeneratorConfig(). Added unit tests.idempotency-headersinseed/ruby-sdk-v2/seed.ymlwithno-custom-config(baseline, no injection) andauto-generate-idempotency-key(enabled). Regenerated: enabledcreate(POST) emitsheaders = { "Idempotency-Key" => request_options[:idempotency_key] || SecureRandom.uuid };delete(DELETE) untouched; baseline emits no injection.featentry undergenerators/ruby-v2/sdk/changes/unreleased/.Testing
@fern-api/api-workspace-commonssuite passes.pnpm seed test --generator ruby-sdk-v2 --fixture idempotency-headers --local --skip-scripts→ 2/2 pass; injection confirmed on POST only, caller-key-wins fallback verified.Link to Devin session: https://app.devin.ai/sessions/dcab1efb11c3438884ebf517443d6b5b
Requested by: @cadesark