diff --git a/README.md b/README.md index 04ae65d..48e41db 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,45 @@ Internal errors follow the [spec-defined error codes](https://www.jsonrpc.org/sp | -32602 | Invalid params | | -32603 | Internal error | +### Selecting public handler errors + +By default, thrown values are serialized using their `message`, numeric `code` (or +`-32000`), and optional `data`. To control which details reach callers, provide a +synchronous `serializeError(error, method)` callback: + +```ts +import { newWorkersRpcResponse, RpcError } from "@jmorrell/jsonrpc"; + +return newWorkersRpcResponse(request, service, { + serializeError(error, method) { + if (error instanceof RpcError) { + return { code: error.code, message: error.message, data: error.data }; + } + return { + code: -32603, + message: "The operation could not be completed. Check its result before retrying.", + }; + }, +}); +``` + +This option is available on `processRpc`, HTTP handlers, `RpcSession`, and both +WebSocket peers. It applies only to service-method failures; protocol errors keep +their standard responses. `onError` still receives `HANDLER_ERROR` with the original +thrown value as its `cause` before serialization. Callbacks must not throw; returning +an error does not undo the handler's effects or make retrying safe. The application +is responsible for selecting safe messages and data, including those on `RpcError`. + +### HTTP response failures + +For non-2xx HTTP responses, the fetch client throws `RpcError` with the HTTP status +as its `code`. An `application/json` response containing a nonempty string `error` +field supplies the message (for example, `{ "error": "Request body is too large" }`). +Otherwise the message is `Request failed (HTTP ).`, even when the response +has an empty status text. HTML, malformed JSON, and other body shapes are not used +as messages. Every call in a rejected HTTP batch receives the same error. These +HTTP failures do not establish whether the server executed the request. + ### `RpcProtocolError` Protocol-level issues (malformed messages, unknown response IDs, transport failures) are reported via the `onError` callback as `RpcProtocolError` instances. Each has a `code` string: diff --git a/src/__tests__/http-batch.test.ts b/src/__tests__/http-batch.test.ts index c5c7f33..7b61e03 100644 --- a/src/__tests__/http-batch.test.ts +++ b/src/__tests__/http-batch.test.ts @@ -293,3 +293,131 @@ describe("newHttpBatchRpcResponse", () => { expect(json).toEqual({ jsonrpc: "2.0", id: 1, result: 7 }); }); }); + +describe("HTTP error explanations", () => { + it("preserves the server's upload limit explanation for every call in a rejected batch", async () => { + let fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + Response.json( + { error: "Request body exceeds the upload limit", maxBytes: 2097152 }, + { status: 413 }, + ), + ); + using client = newHttpBatchRpcSession<{ add(a: number, b: number): number }>( + "https://example.com/jsonrpc", + ); + try { + let results = await Promise.allSettled([client.add(1, 2), client.add(3, 4)]); + expect(results).toEqual([ + { + status: "rejected", + reason: expect.objectContaining({ + code: 413, + message: "Request body exceeds the upload limit", + }), + }, + { + status: "rejected", + reason: expect.objectContaining({ + code: 413, + message: "Request body exceeds the upload limit", + }), + }, + ]); + } finally { + fetch.mockRestore(); + } + }); + + it.each([ + ["text/html", '{"error":"private proxy diagnostic"}'], + ["application/json", "not JSON"], + ["application/json", '{"error":{"stack":"private"}}'], + ["application/json", '{"error":" "}'], + ])("uses a nonempty status fallback for unusable %s responses", async (contentType, body) => { + let fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(body, { status: 503, headers: { "Content-Type": contentType } }), + ); + using client = newHttpBatchRpcSession<{ add(a: number, b: number): number }>( + "https://example.com/jsonrpc", + ); + try { + await expect(client.add(1, 2)).rejects.toMatchObject({ + code: 503, + message: "Request failed (HTTP 503).", + }); + } finally { + fetch.mockRestore(); + } + }); +}); + +describe("public handler errors", () => { + it("serializes batch failures without losing diagnostics or changing protocol errors", async () => { + const unexpected = Object.assign(new Error("private database detail"), { data: "private" }); + const onError = vi.fn(); + const service = { + expected() { + throw new RpcError("Choose another name", -32009, { field: "name" }); + }, + unexpected() { + throw unexpected; + }, + nonError() { + throw "private thrown string"; + }, + success() { + return 42; + }, + }; + const response = await newHttpBatchRpcResponse( + new Request("https://example.com/rpc", { + method: "POST", + body: JSON.stringify( + ["expected", "unexpected", "nonError", "success", "missing"].map((method, id) => ({ + jsonrpc: "2.0", + id, + method, + })), + ), + }), + service, + { + onError, + serializeError(error, method) { + if (error instanceof RpcError) + return { code: error.code, message: error.message, data: error.data }; + return { code: -32603, message: `Could not complete ${method}. Check its result.` }; + }, + }, + ); + expect(await response.json()).toEqual([ + { + jsonrpc: "2.0", + id: 0, + error: { code: -32009, message: "Choose another name", data: { field: "name" } }, + }, + { + jsonrpc: "2.0", + id: 1, + error: { code: -32603, message: "Could not complete unexpected. Check its result." }, + }, + { + jsonrpc: "2.0", + id: 2, + error: { code: -32603, message: "Could not complete nonError. Check its result." }, + }, + { jsonrpc: "2.0", id: 3, result: 42 }, + { jsonrpc: "2.0", id: 4, error: { code: -32601, message: "Method not found" } }, + ]); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "HANDLER_ERROR", cause: unexpected }), + ); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "HANDLER_ERROR", cause: "private thrown string" }), + ); + }); +}); diff --git a/src/__tests__/workers.workers.test.ts b/src/__tests__/workers.workers.test.ts index 3a9ed6a..d1729ac 100644 --- a/src/__tests__/workers.workers.test.ts +++ b/src/__tests__/workers.workers.test.ts @@ -342,3 +342,54 @@ describe("Workers runtime integration tests", () => { }); }); }); + +describe("WebSocket public handler errors", () => { + it("applies each peer's serializer to incoming calls without requiring onError", async () => { + const serverService = { + fail() { + throw new Error("private server detail"); + }, + }; + const clientService = { + fail() { + throw new Error("private client detail"); + }, + }; + const { response, session: server } = newWorkersWebSocketRpcSession< + typeof clientService, + typeof serverService + >(new Request("http://localhost/rpc", { headers: { Upgrade: "websocket" } }), serverService, { + serializeError: (_error, method) => ({ + code: -32603, + message: `Server could not complete ${method}`, + }), + }); + const socket = response.webSocket!; + socket.accept(); + using client = newWebSocketRpcSession( + socket, + clientService, + { + serializeError: (_error, method) => ({ + code: -32001, + message: `Client could not complete ${method}`, + data: { retry: false }, + }), + }, + ); + try { + await expect(client.remote.fail()).rejects.toMatchObject({ + code: -32603, + message: "Server could not complete fail", + data: undefined, + }); + await expect(server.remote.fail()).rejects.toMatchObject({ + code: -32001, + message: "Client could not complete fail", + data: { retry: false }, + }); + } finally { + server.close(); + } + }); +}); diff --git a/src/core.ts b/src/core.ts index ebd166b..7b0acad 100644 --- a/src/core.ts +++ b/src/core.ts @@ -61,6 +61,8 @@ export class RpcProtocolError extends Error { export type RpcHandlerOptions = { onError?: (err: RpcProtocolError) => void; + /** Select the public representation of a handler failure. Must return synchronously. */ + serializeError?: (error: unknown, method: string) => JsonRpcErrorResponse["error"]; }; /** @@ -234,7 +236,9 @@ export async function processSingleRequest( cause: err, }), ); - const { code, message, data } = extractError(err); + const { code, message, data } = options?.serializeError + ? options.serializeError(err, method) + : extractError(err); return errorResponse(id, code, message, data); } } diff --git a/src/http-batch.ts b/src/http-batch.ts index f28a78a..6d8f3a8 100644 --- a/src/http-batch.ts +++ b/src/http-batch.ts @@ -86,7 +86,22 @@ function fetchTransport(options: RpcFetchOptions): RpcRequestFn { body, }); if (!res.ok) { - throw new RpcError(res.statusText, res.status); + let message = `Request failed (HTTP ${res.status}).`; + if ( + res.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase() === "application/json" + ) { + const body: unknown = await res.json().catch(() => null); + if ( + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" && + body.error.trim() + ) { + message = body.error; + } + } + throw new RpcError(message, res.status); } return res.text(); }; diff --git a/src/session.ts b/src/session.ts index 4856708..f4cbb3f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -20,7 +20,7 @@ export type RpcTransport = { close(): void; }; -export type RpcSessionOptions = { +export type RpcSessionOptions = RpcHandlerOptions & { // In bidirectional RPC, both sides send requests with an `id` field that the // peer echoes back in its response. JSON-RPC IDs are shared on the wire — if // both sides independently pick the same ID, responses become ambiguous and @@ -31,7 +31,6 @@ export type RpcSessionOptions = { // automatically; only callers using RpcSession with a raw transport need to // provide it. role?: "initiator" | "acceptor"; // default: 'initiator' - onError?: (err: RpcProtocolError) => void; }; type PendingCall = { @@ -58,7 +57,7 @@ class RpcSessionImpl { this.onError = options?.onError; this.nextId = role === "initiator" ? 1 : -1; this.idStep = role === "initiator" ? 1 : -1; - this.handlerOptions = this.onError ? { onError: this.onError } : undefined; + this.handlerOptions = options; transport.onMessage((message: string) => { let parsed: unknown; diff --git a/src/websocket.ts b/src/websocket.ts index 3b7d697..2306985 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -123,18 +123,14 @@ export function newWorkersWebSocketRpcSession< const transport = createWebSocketTransport(server); const session = new RpcSession(transport, service ?? ({} as TLocal), { + ...options, role: "acceptor", - onError: options?.onError, }); const response = new Response(null, { status: 101, webSocket: client }); return { response, session }; } -type WebSocketRpcSessionOptions = { - onError?: RpcHandlerOptions["onError"]; -}; - /** * Create a WebSocket RPC client session. * Accepts a WebSocket URL (string) or an existing WebSocket instance. @@ -150,13 +146,13 @@ export function newWebSocketRpcSession< >( ws: WebSocket | string, localFunctions?: TLocal, - options?: WebSocketRpcSessionOptions, + options?: RpcHandlerOptions, ): RpcSession { const socket = typeof ws === "string" ? new WebSocket(ws) : ws; const transport = createWebSocketTransport(socket); return new RpcSession(transport, localFunctions ?? ({} as TLocal), { + ...options, role: "initiator", - onError: options?.onError, }); }