Skip to content

fix(core): normalize RPC handler failures - #46946

Open
kitlangton wants to merge 1 commit into
v2from
rpc-handler-errors
Open

fix(core): normalize RPC handler failures#46946
kitlangton wants to merge 1 commit into
v2from
rpc-handler-errors

Conversation

@kitlangton

@kitlangton kitlangton commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Why

An unexpected RPC handler failure bypasses ordinary Effect error recovery for in-process callers, while HTTP callers receive rpc.internal. The same call should have a recoverable failure at either entry point.

What Changes

  • Before: thrown exceptions, defects, undeclared failures, and invalid declared-error payloads escape direct calls as defects. HTTP defect responses can expose the exception message.
  • After: handler defects are logged and become typed { type: "rpc.internal", message: "RPC call failed" } failures. HTTP maps them to 500; its fallback also logs rather than echoes defects outside handler execution.

The existing declared-error encoder stays in place. Valid declared errors, successful results, input/output validation, and interruption retain their behavior.

Same caller, different outcome

For example, inside a scoped Effect.gen, with rpc obtained from Rpc.Service:

const Weather = Rpc.define({
  id: "weather",
  methods: {
    forecast: { input: Schema.String, output: Schema.String },
  },
  events: {},
})

yield* rpc.register(Weather, {
  forecast: () => Effect.die(new Error("backend unavailable")),
})

// The caller is identical before and after this PR.
const result = yield* rpc.client(Weather).forecast("Boston").pipe(
  Effect.catchIf(
    (error) => "type" in error && error.type === "rpc.internal",
    () => Effect.succeed("Weather unavailable"),
  ),
)

// BEFORE: the defect escapes; catchIf never runs and no result is produced.
// AFTER:  result === "Weather unavailable"; the original defect is logged.

The failed handler does not become successful. It becomes an ordinary RPC failure that the caller can choose to recover from, instead of requiring a separate Effect.catchDefect at each call site.

HTTP response for the same handler failure

// BEFORE: HTTP 500, with the handler's exception message exposed.
{
  "_tag": "RpcInternalError",
  "type": "rpc.internal",
  "message": "backend unavailable"
}

// AFTER: HTTP 500, with a generic message; details remain in server logs.
{
  "_tag": "RpcInternalError",
  "type": "rpc.internal",
  "message": "RPC call failed"
}

Scope

Supersedes #46902 with a smaller implementation and only seven regression cases, not the restored RPC test suite. Production changes are confined to core/src/rpc.ts and server/src/handlers/rpc.ts.

All seven tests were run against unmodified v2 at c992716523 and failed for the intended reason:

Cases Before After
Five direct-call cases: defect, throw, raw failure, undeclared error, invalid error payload Defect bypasses recovery Typed rpc.internal, logged once
HTTP handler defect Raw handler message returned Generic HTTP 500
HTTP input-schema defect Raw schema message returned Generic HTTP 500

Unlike #46902, undeclared-error names also stay in logs rather than the public message.

Verification

# packages/core
bun typecheck
bun run test test/rpc-handler-errors.test.ts --rerun-each 5
bun run test

# packages/server
bun typecheck
bun run ../core/script/test.ts test/rpc-handler-errors.test.ts --rerun-each 5
bun run ../core/script/test.ts
  • Both package typechecks pass.
  • Before the fix: 5 Core + 2 HTTP failures. After: 35/35 repeated regression tests pass.
  • Full Core suite: 4022 pass, 39 skip, 0 fail.
  • Full Server suite: 52 pass, 3 skip, 0 fail.
  • A separate runtime probe confirmed that valid declared errors, successful values, invalid-input/output errors, and cancellation remain unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant