fix(contract): stop the handler errors map from being thenable - #2113
Conversation
The errors map passed to handlers returned a constructor for every string key, including `then`, so awaiting it or returning it from an async function never settled. It now unwraps the same keys as createORPCClient (then, toString, valueOf, toJSON, ...) instead of treating them as error codes.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Fixes an infinite hang when the handler errors constructor map is awaited or returned from an async handler. The proxy returned a constructor for every string key including then, making it a never-settling thenable; promise resolution against it blocked forever. One commit, two files.
error-factory.tsgettrap — unwrap keys now fall through toReflect.get(target, code)instead of fabricating a constructor, reusing the sharedRECURSIVE_CLIENT_UNWRAP_KEYSfrom@orpc/client. This is the exact shape of the existingclient.ts,client-safe.ts, androuter-client.tsproxies, so the behavior is consistent across recursive proxies.- Regression test — symbol access and unwrap-key handling are merged into one test that asserts
then/toJSONareundefined,toString/valueOfresolve toObject.prototypemethods, andawait mapresolves to the proxy; the formertoString-code assertion moves toconstructor. It fails without the fix (map.thenwould be a function; theawaitwould hang). - Behavior change — codes named
then,bind,call,apply,valueOf,toString,toJSONcan no longer be invoked as constructors on the map. That is the intended trade-off for the fix and mirrors the reserved-key restriction already documented for routers; reconciliation still usesgetOwnon the raw map, so wire-level matching is unaffected.
Tests pass locally: packages/contract/src/error-factory.test.ts (24 passed).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/experimental-msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will degrade performance by 12.56%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | octet stream |
640.5 µs | 732.6 µs | -12.56% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing dinwwwh:claude/error-handler-thenable-hang-6af435 (a451bfa) with main (eab53d6)
The handler `errors` map now reserves only `then`, instead of every key in `RECURSIVE_CLIENT_UNWRAP_KEYS` as #2113 did. `then` is the only key the runtime reads on its own (promise resolution), so it is the only one that caused a real failure. The others are only reached by explicitly converting or serializing the map. This narrows the behavior change from #2113. ## Fixes - `errors.toString()`, `errors.valueOf()`, `errors.toJSON()`, `errors.bind()`, `errors.call()` and `errors.apply()` build errors with those codes again. - `await errors` and returning `errors` from an async handler still settle instead of hanging. - Adding a key to the client's unwrap set no longer silently takes that name away from error codes. ## Testing - The regression test now checks only symbols and `then`. The Object.prototype lookup test uses `toString()` again and fails if `toString` is reserved. - Contract and server tests (687), eslint, and the `@orpc/contract` type-check pass.

Returning
errorsfrom an async handler (or awaiting it anywhere) no longer hangs forever. The errors map returned a constructor for every string key, includingthen, so promise resolution treated it as a thenable that never settles. It now unwraps the same keys ascreateORPCClient.Fixes
os.handler(async ({ errors }) => errors)andawait errorssettle instead of hanging.String(errors)no longer throws, andJSON.stringify(errors)no longer serializes a made-uptoJSONerror.toStringare unaffected; reconciliation still looks codes up on the raw error map.Behavior change
errors.then(),errors.toString(),errors.valueOf(),errors.toJSON()(andbind/call/apply) no longer build errors with those codes; usenew ORPCError('toString')instead.Testing
@orpc/contracttype-check pass.