Skip to content

feat(service-rpc): idempotency keys make every RPC call safe to retry#140

Merged
wmadden-electric merged 21 commits into
mainfrom
claude/streams-cold-start-rpc-37e5c1
Jul 21, 2026
Merged

feat(service-rpc): idempotency keys make every RPC call safe to retry#140
wmadden-electric merged 21 commits into
mainfrom
claude/streams-cold-start-rpc-37e5c1

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

At a glance

A service calls another service exactly like before — nothing in app code changes:

// consumer: this call is unchanged, but it now survives the target cold-starting
const { ok } = await auth.verify({ token });

What changed is underneath it. Every call now carries an idempotency key, and a dropped first attempt is retried with the same key — which the provider recognises, so the retry can never run the work twice:

POST /rpc/verify                        POST /rpc/verify        ← automatic retry
Idempotency-Key: 9f2c…e1                Idempotency-Key: 9f2c…e1   ← same key
   ✗ connection dropped mid-boot           provider: already ran this key →
                                           replay the answer, don't run it again

A handler that wants the key can take it as an optional third argument; handlers that don't are untouched:

verify: async (input, deps, ctx) => { /* ctx.idempotencyKey — most handlers ignore it */ }

The decision

Every service-RPC call carries an idempotency key, and the framework deduplicates on it. The client mints one key per call and retries dropped calls; the provider runs one call per key and replays a completed answer to a repeat. Because duplicates are absorbed by the protocol, every method is safely retryable and no method declares anything — there is no per-method "is this safe to retry" flag.

Why we need it

A service that has scaled to zero has to boot before it can answer. During that window the platform sometimes drops the caller's first connection — a real, live failure (an internal ticket, PRO-217, tracks it) that until now surfaced to the calling service as an error.

The obvious fix is for the client to retry. The obvious objection is that retrying a POST might run a write twice. Everything below is how we get the retry without the double-execution.

How it works, piece by piece

  1. One key per logical call, reused across retries. The client generates a UUID per call and sends it on every attempt of that call — so two separate calls never collide, and a retry is always recognisable as the same call. It retries a dropped connection, a 429, or any 5xx; every other 4xx surfaces immediately, because a rejected request stays rejected.

  2. The provider deduplicates on the key. A second request with a key still in flight waits for the first (one execution, not two); a second request after the first has answered gets that same answer back without the handler running again.

  3. Only real answers are replayed. A 2xx or a 4xx is a conclusion, so it replays. A 5xx or a thrown error is not — it is the outcome a retry exists to escape, so it is never remembered and a retry re-executes. (These two sets are exact complements of what the client retries, which is what makes replaying a 4xx safe: the client would never have resent it.)

  4. A keyless request opts out, transparently. The generated client always sends a key, so every framework call is deduplicated. A request that arrives without one — a curl, any hand-rolled or older caller — is served once with no dedup rather than rejected. The safe path is the always-keyed client, so a keyless request never came from it; accepting it adds no unsafe retry path and keeps out-of-band callers working.

  5. A handler can reach the key through an optional third argument, to write into its own transaction when it needs exactly-once beyond one instance's memory. Most handlers never need it; existing two-argument handlers are unchanged.

What this is, and isn't, in scope to be

The retry is permanent protocol behaviour, not a workaround for one platform. Keyed retries are correct on any transport that can drop a request, so nothing here is removed when the platform's cold-start behaviour is eventually fixed.

It stays within this kind's remit (ADR-0036): point-to-point internal RPC between two services in one app. One thing it deliberately does not guarantee — a retry that lands on a different provider instance than the one that already did the work. The in-process dedup store can't see across instances; that residue is the handler's to close with the key it now receives, and no supported target's failure modes ask the framework to close it for them.

Three fixes that came along

These were promised when the oRPC PR (#114) was declined, and they touch the exact code this rewrites — so they land here rather than in a colliding PR:

  1. A request body size limit in serve(), enforced against bytes actually read so an absent or lying content-length can't slip past it → 413.
  2. Handler exception messages no longer reach callers — a generic 500 to the caller, the real error to the server log. An input-validation 400 deliberately keeps its detail, because that is the caller's own malformed request and the message is how they fix it.
  3. The client no longer re-validates responsesserve() already validates a handler's output against the schema, and both ends are framework-generated, so the second pass guarded nothing. (It took an unnecessary cast with it.)

Verification

  • Live end-to-end on real Compute: deployed examples/storefront-auth; the storefront→auth round trip succeeded through the keyed client. One honest gap: the retry's rescue of a cold-start drop went unobserved — no drop reproduced against the fast-booting auth service across 14 forced cold starts, so that specific before/after is covered in-process, not live. Full raw output in the PR comment.
  • In-process: service-rpc 54 tests; both example suites (store, storefront-auth) pass unchanged — the proof the new handler argument is non-breaking, including a real cross-service auth.verify() round trip.
  • Every safety-critical property was independently reviewed and mutation-checked (each broken deliberately, the specific test watched to fail, then restored): cross-method replay isolation, key-constant-across-retries, single-flight, 5xx-never-cached, body cap, exception masking.
  • typecheck + pnpm lint clean, lint:casts delta 0, lint:deps clean, test:scripts 149.

Decision, reasoning, and the named residual are recorded in ADR-0037; the protocol is added to connection-contracts.md, gotchas.md, the guides, and the composer skill.

Alternatives considered

  • A per-method idempotent: true flag, retrying only marked methods. Rejected: idempotence is a property of what a handler does to state, so nothing at the RPC layer can verify the claim. The framework would be retrying on an assurance it cannot check — failing silently when a method is marked wrongly — and the safe default guarantees under-marking, so the mechanism would protect almost nothing. The key replaces a promise with a mechanism.
  • Retrying without keys. Rejected: it turns a visible failure into a silent duplicate write, on the assumption that every handler is repeat-safe — the same unverifiable claim, made on the author's behalf.
  • Rejecting a keyless request outright (a 400). Considered, then dropped: the only caller that can omit a key is one outside the generated client, for which "no key, no dedup" is the honest, expected behavior. A hard 400 would break curl and older clients to guard a case the always-keyed safe path already covers — and it made the change a breaking one for no safety gain.
  • Durable, cross-instance deduplication in the framework. Rejected here: it puts a storage dependency behind every provider to close a window no supported target's failure modes call for. A handler that needs it has the key to do it itself.
  • A cold-start canary for this bug (like the streams module's). Built, run live, and dropped: a canary exists to signal when a workaround can be deleted, and this retry is permanent — there is nothing to time the removal of. Separately, the auth service boots too fast (under ~1.5s, no state to restore) to certify a cold start against the coldness-proof margin. PRO-217 stays watched by the streams canary, which catches it because streams has the long boot window RPC lacks.

🤖 Generated with Claude Code

wmadden-electric and others added 16 commits July 20, 2026 15:43
Contract-declared idempotence for rpc methods, the bounded client-side
retry for marked methods only, and the RPC face of the PRO-217 canary,
inheriting every rule from the cold-start canary's rebuild. Execution
held pending operator sign-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Will's design supersedes the per-method idempotent flag: the protocol
requires a client-minted key on every call, serve() implements single-
flight and replay within the retry envelope, handlers may use the key for
durable control but need not. Every method becomes safely retryable with
no declaration to trust. Spec and plan rewritten; scope calibration and
the accepted cross-instance residual recorded in design-notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Paths follow #131 (@internal/service-rpc, src/exports entrypoint). The
accepted residual is rejustified against connection-contracts scope —
internal means inside the topology, cross-network edges are in scope, and
robustness is calibrated against named failure modes — so the slice names
PRO-217 as what it guards and names the cross-instance window it leaves
open. The three fixes promised when #114 was declined are absorbed here
because they touch the same files. ADR-0037 reserved for the protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
An interrupted dispatch wrote protocol.ts under the old rpc/ directory;
it was untracked, so the rebase reset did not clear it and the previous
commit swept it in. The slice writes into service-rpc/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
makeClient() now mints one crypto.randomUUID() per logical call and sends
it as the Idempotency-Key header on every attempt, byte-identical across
retries of that call and never shared with another call. Every method
gets a bounded, jittered retry (250 ms initial, x2, 5 s cap, up to 5
retries): thrown network errors, 429, and any 5xx retry; any other 4xx
does not, since a malformed or unauthorized request stays wrong on a
second try. This exists because PRO-217 (the Prisma Compute ingress
closing a first-touch connection while a scale-to-zero target boots) hits
every service-to-service RPC edge raw today; a blanket retry was unsafe
until every call carried a key a server could dedupe on.

Also drops the client's redundant re-validation of the server's response
against the output schema: serve() already validates a handler's output
before responding, and both ends of every edge are framework-provisioned,
so the second pass only duplicated work. The MethodSchemas blindCast this
required falls out too — the client now only needs method names off
contract.__cmp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…n it

serve() now rejects a keyless request with a loud 400 naming the
Idempotency-Key header. A duplicate key arriving while the first call for
it is still executing single-flights onto that same execution, so the
handler runs exactly once; a duplicate arriving after a completed 2xx/4xx
answer replays it byte-identically for 60 seconds. A 5xx is never cached,
since it's exactly the outcome a retry exists to re-execute. Storage is
keyed by method first, then by idempotency key, so a lookup for one
method can never find another method's cached answer, even if a key is
reused across methods. The cache is LRU-bounded at 1000 entries so it
stays a small, fixed amount of memory per provider process regardless of
traffic.

Handlers gain an optional third argument, ctx: RpcHandlerContext, carrying
ctx.idempotencyKey after the existing deps argument — every existing
two-argument handler still typechecks and runs unchanged. This is a
breaking protocol change: a caller that doesn't go through makeClient (the
client commit landing just before this one) must now supply its own key or
its calls 400.

This is permanent protocol semantics for this kind, not a compensation
tied to any one platform incident — see PRO-217 for the motivating urgency.
In-memory dedupe cannot cover a retry landing on a different instance than
the one that applied the call; that residual is accepted, and the
escalation path is a handler using ctx.idempotencyKey in its own
transaction where its target's failure modes warrant it.

Also lands the two remaining #114 fixes that touch this same dispatch
path:

- Request bodies are capped at 1 MiB, enforced against bytes actually read
  off the stream rather than the caller-supplied content-length (absent or
  lying, the cap still holds), and answered with 413.
- A handler throw or an output-validation failure now masks its message
  behind a generic 500 and logs the real error server-side, so an internal
  exception string never reaches the caller. Input-validation 400s are
  unchanged — that detail is the caller's own malformed request and is how
  they fix it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
serve() now requires every request to carry an Idempotency-Key header;
serveSchedule()'s production callers already get one for free since they
go through makeClient (the trigger dep hydrates via rpc()), but this
suite builds its trigger requests by hand and was failing 400 before
reaching the behavior each test actually exercises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Records the decision the slice implements: one key per logical call,
reused across retries; the server requires it, runs one call per key,
and replays completed answers but never 5xx. No per-method opt-in,
because a flag would be a claim the framework cannot verify. States
what the in-process store does not cover and why that residue is
deliberate, calibrated against connection-contracts scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Adds the idempotency-key protocol to the network-binding section as a
property of the binding, not the contract, alongside the service key.
Corrects the per-call check: the server validates both directions; the
client no longer re-validates the response, since both ends are
framework-generated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ation

scripts/rpc-cold-start-canary.ts + rpc-cold-start-canary-classify.ts (+
its test) mirror cold-start-canary.ts wholesale: create -> upload ->
start -> race the promote call, >=60s spacing including sample #0,
14-confirmed-cold-start-hold bug-gone budget, first-close early exit,
MAX_RUN_MS self-stop, requirable exits. The probe is a bare
single-attempt fetch straight at auth.service's POST /rpc/verify with
a manually minted Idempotency-Key, never through makeClient — this
slice just gave every framework RPC edge an automatic retry, and a
probe through that client would mask the raw platform behavior being
tested. New rpc-cold-start-canary job in e2e-deploy.yml, modeled on
cold-start-canary, same deploy-verify-destroy action and needs:
serialization, not added to any required-checks list.

auth.service never logged anything on boot, so there was no listening
line to read; examples/storefront-auth/modules/auth/src/server.ts now
logs one self-timestamped line right after Bun.serve() returns
(Compute's log relay passes plain app stdout through with no
platform-added timestamp, verified live, so the app has to stamp its
own, the same role streams-server's console.log patch plays for the
streams face).

auth.service is wired to exactly one RPC consumer (storefront), so per
ADR-0030/0031 it enforces a per-edge bearer key this script cannot
obtain: the Management API's own contract says an environment
variable's value "is stored encrypted and is not returned by
subsequent reads," confirmed live (an unauthenticated touch against a
warm instance got back exactly serve()'s documented 401). Sending no
Authorization header is the honest choice, and the classifier treats
that specific, known 401 the same as a 2xx for classification purposes,
since PRO-217's close happens at the ingress before serve()'s
accepted-key check ever runs.

Known limitation, evidenced by a 14-sample live run: auth.service's
boot (spark starting bun -> the app's own listening line) is
consistently well under 2s, so no touch's lead time can cross the
2000ms cross-clock-skew margin cold-start-canary-classify.ts's
technique requires before calling a touch confirmed-cold. Every touch
still raced a real boot (positive, consistent 376-1185ms gaps, 14/14
samples) and the close-decisive path is completely unaffected (a close
needs no boot evidence), so the canary can still catch bug-present, but
it cannot currently earn a bug-gone verdict against this target. Full
numbers and a recommendation are in this dispatch's report to the
orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…anted

An RPC cold-start canary was dispatched but, on the evidence, dropped:

1. It cannot certify a cold start against examples/storefront-auth. The
   auth service restores no state, so it boots in under ~1.5s — narrower
   than the 2s clock-skew margin the coldness proof needs. The live run
   forced 14 real races (touch arrived 376-1185ms before listening, every
   time) yet certified none, correctly refusing to shrink the margin to fit.

2. More fundamentally, a cold-start canary exists to signal when a platform
   workaround can be deleted. The RPC idempotency retry is permanent
   protocol semantics (ADR-0037), not a workaround with an expiry — there
   is nothing for it to signal the removal of. And PRO-217 is a platform
   ingress bug, already watched by the streams cold-start canary, which
   detects it well precisely because streams has the long boot window RPC
   lacks.

The slice ships the keyed protocol; the streams canary remains the
platform-wide PRO-217 signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…anary

The RPC client absorbs the cold-start close with a keyed, dedup-safe
retry that is permanent protocol semantics, so there is no workaround to
retire and no RPC canary; PRO-217 stays watched by the streams canary,
whose long boot window RPC lacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
building-an-app gains a "Calls retry safely for you" section (the key,
the safe retry, the keyless-400, the optional ctx third arg);
getting-started and SKILL get terse pointers to it. Matches the new
serve()/makeClient behavior from ADR-0037.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
- A request body that errors mid-read now masks and logs like every other
  internal failure, instead of rejecting out of serve() (which broke its
  Response contract and skipped the operator log). New test, mutation-checked.
- Add the missing test for output-validation masking (a handler returning
  schema-violating output → masked 500 + provider-bug log), mutation-checked.
- Document why six retry attempts cover a boot up to ~22s (a held connection
  blocks until the server is listening; the budget only governs actively
  closed attempts) and that maxDelayMs is an unreached ceiling at maxRetries:5.
- standard-schema.ts no longer claims the client re-validates responses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric requested a review from wmadden July 21, 2026 07:07
@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@140
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@140

commit: 47107d0

@wmadden-electric

Copy link
Copy Markdown
Contributor Author

Live end-to-end proof on real Prisma Cloud

Deployed examples/storefront-auth to a fresh stack (rpc-e2e-20260721091025), proved the protocol, destroyed it (zero leaked projects). Raw evidence:

The keyed client works end to end. The storefront server-renders by calling auth.verify() and auth.secretCheck() through the hydrated client — every call carrying Idempotency-Key + Authorization: Bearer <serviceKey> and riding the retry path:

<main><h1>Storefront</h1><p>Auth /verify says: true</p><p>Secret /check says: true</p></main>

Wire enforcement, confirmed live. A bare POST /rpc/verify with neither header:

HTTP/2 401
{"error":"Unauthorized: missing or invalid service key"}

That is the service-key error, not the idempotency-key error — proving the auth check runs before the key check, matching the code's order. The keyless-400 itself couldn't be probed live (it sits behind auth, and the service key is encrypted / not returned by the Management API); it stays covered by the mutation-tested unit tests.

The cold-start rescue — honest inconclusive. Forced 8 independent fresh auth instances (create → upload → start → race-promote, the canary's technique; each boot log showed compute.manifest.json not found … downloading source archive, i.e. genuinely fresh). At each cold moment I fired the storefront's keyed retrying client and a bare single-attempt probe concurrently:

Round Bare probe Storefront (keyed client)
0 401, 1210ms 200, 1904ms, true/true
1 401, 1744ms 200, 1860ms, true/true
7 401, 1074ms 200, 1203ms, true/true

All 8 rounds held cleanly on both sides — no PRO-217 close fired today, so there was no close for the retry to rescue. This is not a gap in the PR: the auth service boots in under ~2s (no state to restore), which narrows or removes the close window — the same structural reason the RPC canary was dropped. The keyed path is proven correct against 8 fresh instances; the before/after rescue contrast is inherently hard to force on demand for an intermittent bug against a fast-booting target, and this was a small sample. Reported as observed, not overclaimed.

Dedup wasn't observable live (reaching the dedup store requires the service key); it rests on the in-process mutation tests.

Verdict: the keyed protocol works end-to-end on real Compute. The retry's cold-start rescue went unexercised (no close reproduced), not disproven.

@wmadden-electric wmadden-electric changed the title feat(service-rpc)!: calls carry an idempotency key — safe cold-start retries, no per-method flag feat(service-rpc)!: idempotency keys make every RPC call safe to retry Jul 21, 2026

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preemptively approved. Fix the cosmetic issues then merge

Comment thread packages/0-framework/2-authoring/service-rpc/src/client.ts Outdated
Comment thread packages/0-framework/2-authoring/service-rpc/src/client.ts Outdated
Comment thread packages/0-framework/2-authoring/service-rpc/src/serve.ts Outdated
wmadden-electric and others added 3 commits July 21, 2026 11:08
A request without an Idempotency-Key is now served once, without dedup or
replay, instead of being rejected. The safe path is the generated client,
which always pairs a key with the retry, so a keyless request never came
from it — accepting it adds no unsafe retry path and lets curl and older
callers work transparently, degrading gracefully instead of hard-breaking.
ctx.idempotencyKey becomes string | undefined.

Also: trim the oversized code comments down to what earns its place, and
rewrite ADR-0037 to build the accepted mechanism directly (the rejected
flag moves wholly to Alternatives) and to drop project-temporal framing.
The keyless-400 wording is updated across connection-contracts, the guides,
the skill, the ADR index, and the slice spec/plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Remove the idempotency paragraph from serve()'s module header (the
IdempotencyStore doc and the dispatch code already carry it) and tighten
client.ts's header to its essentials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@wmadden, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e2c9a55d-52ff-47cf-ae71-737ef6276a12

📥 Commits

Reviewing files that changed from the base of the PR and between b302634 and 47107d0.

📒 Files selected for processing (1)
  • packages/0-framework/2-authoring/service-rpc/src/serve.ts

Walkthrough

Service RPC clients now attach per-call idempotency keys and retry selected transient failures while reusing the same key. Servers validate bounded request bodies, mask internal failures, pass keys through handler context, deduplicate concurrent keyed calls, and replay cached 2xx/4xx outcomes with bounded retention. Tests cover client retries, server replay and single-flight behavior, keyless requests, validation, body limits, error handling, and handler typing. Design records and guides document the protocol and dropped canary.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: protocol-wide idempotency keys that make service-RPC calls retry-safe.
Description check ✅ Passed The description directly describes the keyed retry protocol, handler context, and related docs/tests, matching the changeset.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/streams-cold-start-rpc-37e5c1
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/streams-cold-start-rpc-37e5c1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wmadden-electric wmadden-electric changed the title feat(service-rpc)!: idempotency keys make every RPC call safe to retry feat(service-rpc): idempotency keys make every RPC call safe to retry Jul 21, 2026
@wmadden-electric
wmadden-electric enabled auto-merge (squash) July 21, 2026 09:12

@coderabbitai coderabbitai 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.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.drive/projects/forcing-function-apps/design-notes.md:
- Around line 197-204: Update the idempotency protocol description in the
surrounding serve/client design section to state that generated clients always
send an Idempotency-Key, while the server also accepts keyless opt-out requests
and processes them without deduplication. Replace the claim that every call
requires a key, preserving the existing retry, single-flight, and replay
behavior for keyed requests.

In @.drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md:
- Around line 78-87: Remove stale shipped-canary requirements from the RPC
cold-start records: in
.drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md lines 78-87,
replace “canary verify” with the retained keyed live verification; in spec.md
line 1, remove shipped-canary framing from the title; in spec.md lines 130-149,
mark the canary section historical or remove it from the active outcome; and in
spec.md lines 189-191, remove the live canary verdict requirement.

In @.drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md:
- Around line 61-64: Correct the Handler context documentation to describe the
context as an optional third argument, `(input, deps, ctx)`, appended after the
existing `deps` argument. Preserve the compatibility statement that existing
`(input, deps)` handlers remain valid and may ignore `ctx.idempotencyKey`.

In
`@docs/design/90-decisions/ADR-0037-service-rpc-calls-carry-an-idempotency-key.md`:
- Around line 3-5: Remove the `Status` section and its `Accepted` value from
ADR-0037, leaving the decision state implicit and matching the repository’s ADR
format.

In `@docs/guides/getting-started.md`:
- Around line 332-338: The generated client documentation overstates retry
deduplication as an unconditional guarantee. Update the paragraph around the
generated client’s idempotency-key behavior to state that deduplication is
limited to the provider instance’s bounded TTL/LRU store, and direct readers to
durable deduplication for exactly-once behavior beyond that scope; preserve the
existing explanation that hand-rolled requests without keys still work.

In `@packages/0-framework/2-authoring/service-rpc/src/client.ts`:
- Around line 81-94: Update the retry branch in the RPC request loop around
isRetryableStatus and RETRY.maxRetries to consume or cancel the retryable
response body before sleeping and issuing the next attempt. Preserve the
existing terminal errorDetail handling and retry backoff behavior.
- Around line 61-79: The callWithRetry function currently awaits send
indefinitely when a transport stalls. Add a per-attempt timeout using an
AbortSignal or equivalent around send(buildRequest()), ensure timeout failures
enter the existing catch-and-retry path, and reset or recreate the timeout
signal for each loop iteration while preserving the current retry and backoff
behavior.

In `@packages/0-framework/2-authoring/service-rpc/src/serve.ts`:
- Around line 282-289: Update the serve.ts route documentation (around the POST
/rpc/<method> description) to state that Idempotency-Key enables
deduplication/replay, while keyless calls are accepted and run once; update
exports/index.ts documentation to describe the same optional-key behavior. No
runtime logic changes are needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 002616cf-bc71-4b67-a030-afa1c37a43ba

📥 Commits

Reviewing files that changed from the base of the PR and between 842c125 and b302634.

📒 Files selected for processing (18)
  • .drive/projects/forcing-function-apps/design-notes.md
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md
  • docs/design/10-domains/connection-contracts.md
  • docs/design/90-decisions/ADR-0037-service-rpc-calls-carry-an-idempotency-key.md
  • docs/design/90-decisions/README.md
  • docs/guides/building-an-app.md
  • docs/guides/getting-started.md
  • gotchas.md
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/client.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/serve-handlers.test-d.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/serve.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/client.ts
  • packages/0-framework/2-authoring/service-rpc/src/exports/index.ts
  • packages/0-framework/2-authoring/service-rpc/src/serve.ts
  • packages/0-framework/2-authoring/service-rpc/src/standard-schema.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/serve-schedule.test.ts
  • skills/prisma-composer/SKILL.md

@coderabbitai coderabbitai 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.drive/projects/forcing-function-apps/design-notes.md:
- Around line 197-204: Update the idempotency protocol description in the
surrounding serve/client design section to state that generated clients always
send an Idempotency-Key, while the server also accepts keyless opt-out requests
and processes them without deduplication. Replace the claim that every call
requires a key, preserving the existing retry, single-flight, and replay
behavior for keyed requests.

In @.drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md:
- Around line 78-87: Remove stale shipped-canary requirements from the RPC
cold-start records: in
.drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md lines 78-87,
replace “canary verify” with the retained keyed live verification; in spec.md
line 1, remove shipped-canary framing from the title; in spec.md lines 130-149,
mark the canary section historical or remove it from the active outcome; and in
spec.md lines 189-191, remove the live canary verdict requirement.

In @.drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md:
- Around line 61-64: Correct the Handler context documentation to describe the
context as an optional third argument, `(input, deps, ctx)`, appended after the
existing `deps` argument. Preserve the compatibility statement that existing
`(input, deps)` handlers remain valid and may ignore `ctx.idempotencyKey`.

In
`@docs/design/90-decisions/ADR-0037-service-rpc-calls-carry-an-idempotency-key.md`:
- Around line 3-5: Remove the `Status` section and its `Accepted` value from
ADR-0037, leaving the decision state implicit and matching the repository’s ADR
format.

In `@docs/guides/getting-started.md`:
- Around line 332-338: The generated client documentation overstates retry
deduplication as an unconditional guarantee. Update the paragraph around the
generated client’s idempotency-key behavior to state that deduplication is
limited to the provider instance’s bounded TTL/LRU store, and direct readers to
durable deduplication for exactly-once behavior beyond that scope; preserve the
existing explanation that hand-rolled requests without keys still work.

In `@packages/0-framework/2-authoring/service-rpc/src/client.ts`:
- Around line 81-94: Update the retry branch in the RPC request loop around
isRetryableStatus and RETRY.maxRetries to consume or cancel the retryable
response body before sleeping and issuing the next attempt. Preserve the
existing terminal errorDetail handling and retry backoff behavior.
- Around line 61-79: The callWithRetry function currently awaits send
indefinitely when a transport stalls. Add a per-attempt timeout using an
AbortSignal or equivalent around send(buildRequest()), ensure timeout failures
enter the existing catch-and-retry path, and reset or recreate the timeout
signal for each loop iteration while preserving the current retry and backoff
behavior.

In `@packages/0-framework/2-authoring/service-rpc/src/serve.ts`:
- Around line 282-289: Update the serve.ts route documentation (around the POST
/rpc/<method> description) to state that Idempotency-Key enables
deduplication/replay, while keyless calls are accepted and run once; update
exports/index.ts documentation to describe the same optional-key behavior. No
runtime logic changes are needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 002616cf-bc71-4b67-a030-afa1c37a43ba

📥 Commits

Reviewing files that changed from the base of the PR and between 842c125 and b302634.

📒 Files selected for processing (18)
  • .drive/projects/forcing-function-apps/design-notes.md
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md
  • docs/design/10-domains/connection-contracts.md
  • docs/design/90-decisions/ADR-0037-service-rpc-calls-carry-an-idempotency-key.md
  • docs/design/90-decisions/README.md
  • docs/guides/building-an-app.md
  • docs/guides/getting-started.md
  • gotchas.md
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/client.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/serve-handlers.test-d.ts
  • packages/0-framework/2-authoring/service-rpc/src/__tests__/serve.test.ts
  • packages/0-framework/2-authoring/service-rpc/src/client.ts
  • packages/0-framework/2-authoring/service-rpc/src/exports/index.ts
  • packages/0-framework/2-authoring/service-rpc/src/serve.ts
  • packages/0-framework/2-authoring/service-rpc/src/standard-schema.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/__tests__/serve-schedule.test.ts
  • skills/prisma-composer/SKILL.md
🛑 Comments failed to post (8)
.drive/projects/forcing-function-apps/design-notes.md (1)

197-204: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clarify that the generated client, not every request, requires a key.

This currently says the protocol requires Idempotency-Key, but the same section explicitly permits keyless requests. State that generated clients always send a key, while the server accepts keyless opt-out requests without deduplication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.drive/projects/forcing-function-apps/design-notes.md around lines 197 -
204, Update the idempotency protocol description in the surrounding serve/client
design section to state that generated clients always send an Idempotency-Key,
while the server also accepts keyless opt-out requests and processes them
without deduplication. Replace the claim that every call requires a key,
preserving the existing retry, single-flight, and replay behavior for keyed
requests.
.drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md (1)

78-87: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove stale RPC-canary requirements from the slice records.

The canary was explicitly attempted and dropped, but both the dispatch plan and spec still treat it as a shipped D3 verification requirement.

  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md#L78-L87: replace “canary verify” with the retained keyed live verification.
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md#L1-L1: remove the shipped-canary framing from the title.
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md#L130-L149: mark the canary section historical or remove it from the active outcome.
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md#L189-L191: remove the requirement for a live canary verdict.
📍 Affects 2 files
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md#L78-L87 (this comment)
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md#L1-L1
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md#L130-L149
  • .drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md#L189-L191
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md around
lines 78 - 87, Remove stale shipped-canary requirements from the RPC cold-start
records: in .drive/projects/forcing-function-apps/slices/rpc-cold-start/plan.md
lines 78-87, replace “canary verify” with the retained keyed live verification;
in spec.md line 1, remove shipped-canary framing from the title; in spec.md
lines 130-149, mark the canary section historical or remove it from the active
outcome; and in spec.md lines 189-191, remove the live canary verdict
requirement.
.drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md (1)

61-64: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the handler argument position.

(input, deps, ctx) is a third argument, not a second argument. This must match the implementation and the documented compatibility contract so existing (input, deps) handlers remain valid and ctx.idempotencyKey is exposed in the correct position.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.drive/projects/forcing-function-apps/slices/rpc-cold-start/spec.md around
lines 61 - 64, Correct the Handler context documentation to describe the context
as an optional third argument, `(input, deps, ctx)`, appended after the existing
`deps` argument. Preserve the compatibility statement that existing `(input,
deps)` handlers remain valid and may ignore `ctx.idempotencyKey`.
docs/design/90-decisions/ADR-0037-service-rpc-calls-carry-an-idempotency-key.md (1)

3-5: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the Status section.

The ADR index explicitly requires no per-ADR Status section because it becomes stale. Keep the decision’s accepted state implicit, consistent with the repository ADR format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/design/90-decisions/ADR-0037-service-rpc-calls-carry-an-idempotency-key.md`
around lines 3 - 5, Remove the `Status` section and its `Accepted` value from
ADR-0037, leaving the decision state implicit and matching the repository’s ADR
format.
docs/guides/getting-started.md (1)

332-338: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Overclaims the dedup guarantee — no scope caveat.

"a retry never runs your handler twice" is stated unconditionally, but the dedup store is TTL- and LRU-bounded and scoped to one provider instance (per PR objectives and the SKILL.md text for the same feature, which hedges with "if it needs exactly-once beyond one instance's memory"). A beginner reading only this guide could skip durable dedup for operations that actually need it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/guides/getting-started.md` around lines 332 - 338, The generated client
documentation overstates retry deduplication as an unconditional guarantee.
Update the paragraph around the generated client’s idempotency-key behavior to
state that deduplication is limited to the provider instance’s bounded TTL/LRU
store, and direct readers to durable deduplication for exactly-once behavior
beyond that scope; preserve the existing explanation that hand-rolled requests
without keys still work.
packages/0-framework/2-authoring/service-rpc/src/client.ts (2)

61-79: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and related transport/request types.
git ls-files packages/0-framework/2-authoring/service-rpc/src
printf '\n--- client.ts (relevant range) ---\n'
cat -n packages/0-framework/2-authoring/service-rpc/src/client.ts | sed -n '1,220p'

printf '\n--- search for buildRequest / AbortSignal / timeout in service-rpc ---\n'
rg -n "buildRequest|AbortSignal|timeout|Transport|callWithRetry|sleep\\(" packages/0-framework/2-authoring/service-rpc/src packages/0-framework/2-authoring/service-rpc -g '!**/dist/**' -g '!**/build/**'

printf '\n--- gotchas docs mentioning transport failures ---\n'
rg -n "ECONNRESET|timeout|AbortSignal|hang|stalled|response withheld|retry" packages/0-framework/2-authoring/service-rpc -g '!*node_modules*' -g '!**/dist/**' -g '!**/build/**'

Repository: prisma/composer

Length of output: 10866


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the surrounding file structure and relevant symbols in the package.
ast-grep outline packages/0-framework/2-authoring/service-rpc/src/client.ts --view expanded || true
printf '\n--- siblings in src ---\n'
find packages/0-framework/2-authoring/service-rpc/src -maxdepth 2 -type f | sort

Repository: prisma/composer

Length of output: 1636


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the surrounding source and any nearby tests or docs if present.
for f in \
  packages/0-framework/2-authoring/service-rpc/src/client.ts \
  packages/0-framework/2-authoring/service-rpc/src/index.ts \
  packages/0-framework/2-authoring/service-rpc/README.md \
  packages/0-framework/2-authoring/service-rpc/gotchas.md
do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    wc -l "$f"
    cat -n "$f" | sed -n '1,260p'
  fi
done

Repository: prisma/composer

Length of output: 5330


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the transport type and request builder definitions used by service-rpc.
rg -n "type Transport|interface Transport|buildRequest\\(|RequestInit|Request\\b|AbortSignal|timeout" packages/0-framework -g '!**/dist/**' -g '!**/build/**'

Repository: prisma/composer

Length of output: 6664


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect client.ts and nearby helpers with line numbers for context.
FILE=packages/0-framework/2-authoring/service-rpc/src/client.ts
if [ -f "$FILE" ]; then
  nl -ba "$FILE" | sed -n '1,220p'
fi

Repository: prisma/composer

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any request-level timeout handling in the package.
rg -n "AbortSignal\\.timeout|signal\\s*:|timeout\\s*:" packages/0-framework/2-authoring/service-rpc -g '!**/dist/**' -g '!**/build/**'

Repository: prisma/composer

Length of output: 153


Add a per-attempt timeout to callWithRetry
A hung transport or a peer that never responds leaves send(buildRequest()) awaiting forever, so the retry/backoff loop never gets a chance to run. Add a timeout/AbortSignal per attempt so stalled calls fail fast and enter the existing retry path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/0-framework/2-authoring/service-rpc/src/client.ts` around lines 61 -
79, The callWithRetry function currently awaits send indefinitely when a
transport stalls. Add a per-attempt timeout using an AbortSignal or equivalent
around send(buildRequest()), ensure timeout failures enter the existing
catch-and-retry path, and reset or recreate the timeout signal for each loop
iteration while preserving the current retry and backoff behavior.

81-94: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,220p' packages/0-framework/2-authoring/service-rpc/src/client.ts

Repository: prisma/composer

Length of output: 4289


🌐 Web query:

Node undici unread response body connection leak pooled connection cancel body docs retryable non-OK

💡 Result:

In Undici, leaving a response body unread is a common cause of connection leaks and performance degradation [1][2][3]. Because Node.js lacks the aggressive, deterministic garbage collection found in browsers, Undici requires that you explicitly consume or cancel every response body to ensure the underlying TCP connection can be reliably returned to the connection pool for reuse [1][2][3]. If you do not need the response body, failing to handle it prevents the connection from being reused, which can eventually lead to request stalls, memory buildup, or exhaustion of the connection pool [2][3][4]. Recommended practices for managing response bodies: 1. Consume the body: Fully consuming the stream (e.g., via for-await-of or other consumption methods) is the safest way to ensure the connection is returned to the pool [3][4]. 2. Use body.dump: For request or fetch, calling await response.body.dump() is the recommended method to discard unwanted response data [5][3][4]. This method consumes the body up to a predefined limit to keep the connection alive (if possible) and handles the cleanup necessary to release the connection [5][4]. 3. Cancellation: You can use response.body.cancel() or destroy the stream to abort the request [4][6]. Note that aborting a request forces Undici to close the underlying TCP connection, which may be less efficient than a clean consumption if you intend to make further requests to the same origin [5][3]. 4. HEAD Requests: If you only need headers, use the HTTP HEAD method to avoid receiving a body entirely, obviating the need for manual consumption or cancellation [2][3]. Regarding retryable non-OK responses: When using RetryAgent or RetryHandler, the library automatically attempts to retry requests that fail due to specific transport errors or configured HTTP status codes (defaulting to 500, 502, 503, 504, 429) [7][8]. Crucially, the RetryHandler cannot automatically retry requests with stateful bodies (such as streams or AsyncIterables) because they cannot be re-read after the first attempt [8][9]. If you encounter a retryable status code, ensure your application logic handles the response body correctly; if you need to inspect the body of a "non-OK" response that triggered a retry, you must configure the RetryAgent appropriately, as it may otherwise throw a RequestRetryError and prevent access to the body [7][10][11]. Always ensure your retry logic does not inadvertently lead to duplicate writes by attempting to re-send partially consumed or stateful request bodies [12][8].

Citations:


Consume retryable response bodies before retrying

429/5xx responses are dropped unread here, which can keep pooled connections pinned under repeated retries. Cancel the body before the next attempt:

🔧 Proposed fix
     if (!isRetryableStatus(res.status) || retries >= RETRY.maxRetries) {
       const detail = await errorDetail(res);
       throw new Error(
         `RPC call "${method}" failed: ${res.status} ${res.statusText}` +
           (detail !== undefined ? ` — ${detail}` : ''),
       );
     }
 
+    await res.body?.cancel();
     retries += 1;
     await sleep(Math.random() * delay);
     delay = Math.min(delay * RETRY.multiplier, RETRY.maxDelayMs);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    if (res.ok) return res.json();

    if (!isRetryableStatus(res.status) || retries >= RETRY.maxRetries) {
      const detail = await errorDetail(res);
      throw new Error(
        `RPC call "${method}" failed: ${res.status} ${res.statusText}` +
          (detail !== undefined ? ` — ${detail}` : ''),
      );
    }

    await res.body?.cancel();
    retries += 1;
    await sleep(Math.random() * delay);
    delay = Math.min(delay * RETRY.multiplier, RETRY.maxDelayMs);
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/0-framework/2-authoring/service-rpc/src/client.ts` around lines 81 -
94, Update the retry branch in the RPC request loop around isRetryableStatus and
RETRY.maxRetries to consume or cancel the retryable response body before
sleeping and issuing the next attempt. Preserve the existing terminal
errorDetail handling and retry backoff behavior.
packages/0-framework/2-authoring/service-rpc/src/serve.ts (1)

282-289: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docs still say the Idempotency-Key is required, but keyless requests are now accepted. After the opt-out refactor the server runs a keyless call once (no 400/rejection); the "requires" wording contradicts the implemented behavior and the keyless tests.

  • packages/0-framework/2-authoring/service-rpc/src/serve.ts#L282-L289: reword "requires an Idempotency-Key" to reflect that a key enables dedupe/replay while a keyless call opts out and runs once.
  • packages/0-framework/2-authoring/service-rpc/src/exports/index.ts#L9-L12: change "the server requires it and dedupes on it" to note the server dedupes when a key is present and otherwise runs the call once.
📍 Affects 2 files
  • packages/0-framework/2-authoring/service-rpc/src/serve.ts#L282-L289 (this comment)
  • packages/0-framework/2-authoring/service-rpc/src/exports/index.ts#L9-L12
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/0-framework/2-authoring/service-rpc/src/serve.ts` around lines 282 -
289, Update the serve.ts route documentation (around the POST /rpc/<method>
description) to state that Idempotency-Key enables deduplication/replay, while
keyless calls are accepted and run once; update exports/index.ts documentation
to describe the same optional-key behavior. No runtime logic changes are needed.

@wmadden-electric

Copy link
Copy Markdown
Contributor Author

Heads-up from the closure of #135: that PR is superseded by this one, but only two of its three fixes exist here (masked 500s, no client re-validation). The third — the request body size limit (413 over a default 1 MiB, checked on declared content-length before reading and on measured bytes before parse) — was publicly promised in the #114 evaluation comment and is now homeless. It belongs in this PR: it slots into the request-reading path before parse, and a 413 is a 4xx so it composes with the replay-cache rule. Reference implementation with tests is on the closed branch claude/service-rpc-hardening (commit 5817350).

wmadden-electric and others added 2 commits July 21, 2026 11:21
serve.ts embedded a raw NUL (0x00) as the method/key separator in the
idempotency store's LRU key. A NUL makes git classify the whole file as
binary, so GitHub rendered "Binary file not shown" and hid the entire
diff. Written as the `\0` escape instead: identical runtime separator
(still collision-proof — neither a method name nor a UUID contains NUL),
text source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric merged commit 069a668 into main Jul 21, 2026
12 of 13 checks passed
@wmadden-electric
wmadden-electric deleted the claude/streams-cold-start-rpc-37e5c1 branch July 21, 2026 09:25
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