diff --git a/apps/content/docs/openapi/link.mdx b/apps/content/docs/openapi/link.mdx index a890190636..dadee9cdfe 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -242,9 +242,27 @@ const link = new OpenAPILink(contract, { }) ``` +## Error Responses + +When the server answers with an error, `OpenAPILink` throws an `ORPCError` whose `cause` is a `ResponseError` carrying the resolved response, so details that are not part of the error itself, such as its status or headers, stay available (errors returned by [`customErrorResponseBodyDecoder`](#custom-error-decoding) are thrown as-is): + +```ts +import { onError, ORPCError, ResponseError } from '@orpc/client' + +const link = new OpenAPILink(contract, { + interceptors: [ + onError((error) => { + if (error instanceof ORPCError && error.cause instanceof ResponseError) { + console.error('Request failed:', error.cause.response.status, error.cause.response.headers['x-request-id']) + } + }), + ], +}) +``` + ## Malformed Responses -When `OpenAPILink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it throws a `MalformedResponseError` instead of an `ORPCError`. Its message is inferred from the body or status, and it carries the resolved response: +When `OpenAPILink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it throws a `MalformedResponseError` instead of an `ORPCError`. It extends `ResponseError`, so it carries the resolved response, and its message is inferred from the body or status: ```ts import { MalformedResponseError, onError } from '@orpc/client' diff --git a/apps/content/docs/rpc/link.mdx b/apps/content/docs/rpc/link.mdx index 2a4dc94f1a..cf817f80b5 100644 --- a/apps/content/docs/rpc/link.mdx +++ b/apps/content/docs/rpc/link.mdx @@ -267,9 +267,27 @@ const link = new RPCLink({ }) ``` +## Error Responses + +When the server answers with an error, `RPCLink` throws an `ORPCError` whose `cause` is a `ResponseError` carrying the resolved response, so details that are not part of the error itself, such as its status or headers, stay available: + +```ts +import { onError, ORPCError, ResponseError } from '@orpc/client' + +const link = new RPCLink({ + interceptors: [ + onError((error) => { + if (error instanceof ORPCError && error.cause instanceof ResponseError) { + console.error('Request failed:', error.cause.response.status, error.cause.response.headers['x-request-id']) + } + }), + ], +}) +``` + ## Malformed Responses -When `RPCLink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it throws a `MalformedResponseError` instead of an `ORPCError`. Its message is inferred from the body or status, and it carries the resolved response: +When `RPCLink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it throws a `MalformedResponseError` instead of an `ORPCError`. It extends `ResponseError`, so it carries the resolved response, and its message is inferred from the body or status: ```ts import { MalformedResponseError, onError } from '@orpc/client' diff --git a/packages/client/src/adapters/standard/rpc-link-codec.test.ts b/packages/client/src/adapters/standard/rpc-link-codec.test.ts index 668025439d..1de73f6298 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.test.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.test.ts @@ -1,5 +1,5 @@ import type { StandardUrl } from '@standard-server/core' -import { MalformedResponseError, ORPCError } from '../../error' +import { MalformedResponseError, ORPCError, ResponseError } from '../../error' import { RPCSerializer } from '../../rpc-serializer' import { RPCLinkCodec } from './rpc-link-codec' @@ -291,9 +291,9 @@ describe('rpcLinkCodec', () => { const error = new ORPCError('NOT_FOUND', { message: 'Resource not found', data: { id: '123' } }) const serialized = serializer.serialize(error.toJSON()) - const result = await codec.decodeResponse({ + const result: any = await codec.decodeResponse({ status: 404, - headers: {}, + headers: { 'x-request-id': 'abc' }, resolveBody: () => Promise.resolve(serialized), }) @@ -301,6 +301,12 @@ describe('rpcLinkCodec', () => { kind: 'error', error: expect.objectContaining({ code: 'NOT_FOUND', message: 'Resource not found', data: { id: '123' } }), }) + + expect(result.error).toBeInstanceOf(ORPCError) + expect(result.error.cause).toBeInstanceOf(ResponseError) + expect(result.error.cause).not.toBeInstanceOf(MalformedResponseError) + expect(result.error.cause.message).toBe('Received a 404 response.') + expect(result.error.cause.response).toEqual({ status: 404, headers: { 'x-request-id': 'abc' }, body: serialized }) }) it('throws MalformedResponseError on non-ORPCError error responses', async () => { diff --git a/packages/client/src/adapters/standard/rpc-link-codec.ts b/packages/client/src/adapters/standard/rpc-link-codec.ts index c81fafc80a..ad5524d55f 100644 --- a/packages/client/src/adapters/standard/rpc-link-codec.ts +++ b/packages/client/src/adapters/standard/rpc-link-codec.ts @@ -5,7 +5,7 @@ import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '../sta import { isAsyncIteratorObject, pathToHttpPath, stringifyJSON, value } from '@orpc/shared' import { mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' import { toStandardHeaders } from '@standard-server/fetch' -import { MalformedResponseError } from '../../error' +import { MalformedResponseError, ResponseError } from '../../error' import { createORPCErrorFromJson, isORPCErrorJson } from '../../error-utils' import { RPCSerializer } from '../../rpc-serializer' @@ -142,7 +142,12 @@ export class RPCLinkCodec implements StandardLinkCodec< if (!isOk) { if (isORPCErrorJson(deserialized)) { - return { kind: 'error', error: createORPCErrorFromJson(deserialized) } + return { + kind: 'error', + error: createORPCErrorFromJson(deserialized, { + cause: new ResponseError({ response: { status: response.status, headers: response.headers, body } }), + }), + } } throw new MalformedResponseError({ response: { headers: response.headers, status: response.status, body } }) diff --git a/packages/client/src/error.test.ts b/packages/client/src/error.test.ts index 243e0e32e0..f8f3ef3288 100644 --- a/packages/client/src/error.test.ts +++ b/packages/client/src/error.test.ts @@ -1,5 +1,5 @@ import { NullProtoObj } from '@orpc/shared' -import { MalformedResponseError, ORPCError } from './error' +import { MalformedResponseError, ORPCError, ResponseError } from './error' describe('oRPCError', () => { it('works', () => { @@ -111,6 +111,29 @@ describe('oRPCError', () => { }) }) +describe('responseError', () => { + it('works', () => { + const response = { status: 404, headers: { 'x-request-id': 'abc' }, body: { message: 'ignored' } } + const cause = new Error('cause') + const error = new ResponseError({ message: 'custom message', response, cause }) + + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(ORPCError) + expect(error).not.toBeInstanceOf(MalformedResponseError) + expect(error.name).toBe('ResponseError') + expect(error.message).toBe('custom message') + expect(error.response).toBe(response) + expect(error.cause).toBe(cause) + }) + + it('infers message from the response status', () => { + const error = new ResponseError({ response: { status: 404, headers: {}, body: { message: 'ignored' } } }) + + expect(error.message).toBe('Received a 404 response.') + expect('cause' in error).toBe(false) + }) +}) + describe('malformedResponseError', () => { it('works', () => { const response = { status: 500, headers: { 'x-header': 'value' }, body: { something: 'unexpected' } } @@ -118,6 +141,7 @@ describe('malformedResponseError', () => { const error = new MalformedResponseError({ message: 'Invalid RPC response format.', response, cause }) expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(ResponseError) expect(error).not.toBeInstanceOf(ORPCError) expect(error.name).toBe('MalformedResponseError') expect(error.message).toBe('Invalid RPC response format.') diff --git a/packages/client/src/error.ts b/packages/client/src/error.ts index bc39c44144..e330fbb13a 100644 --- a/packages/client/src/error.ts +++ b/packages/client/src/error.ts @@ -154,19 +154,46 @@ export interface ORPCErrorJSON extends Pick export type AnyORPCErrorJSON = ORPCErrorJSON -export interface MalformedResponseErrorOptions extends ErrorOptions { +export interface ResponseErrorOptions extends ErrorOptions { /** - * @default inferred from the response body or status + * @default inferred from the response status */ message?: string response: StandardResponse } +/** + * Error carrying the resolved response a link received. Links set it as the `cause` + * of an `ORPCError` decoded from an error response, so details that are not part + * of the error itself, such as its status or headers, stay available. + * + * @see {@link https://orpc.dev/docs/rpc/link#error-responses | RPC Link - Error Responses} + * @see {@link https://orpc.dev/docs/openapi/link#error-responses | OpenAPI Link - Error Responses} + */ +export class ResponseError extends Error { + override readonly name: string = 'ResponseError' + + response: StandardResponse + + constructor(options: ResponseErrorOptions) { + super(options.message ?? `Received a ${options.response.status} response.`, options) + + this.response = options.response + } +} + +export interface MalformedResponseErrorOptions extends ResponseErrorOptions { + /** + * @default inferred from the response body or status + */ + message?: string +} + /** * Error thrown by links when a response does not follow the expected oRPC format, - * for example when a proxy or gateway answers instead of your handler. Carries the - * resolved response. Unless overridden via `options.message`, the message is - * inferred from the response body or status. + * for example when a proxy or gateway answers instead of your handler. Extends + * `ResponseError`, so it carries the resolved response. Unless overridden via + * `options.message`, the message is inferred from the response body or status. * * Unlike `ORPCError`, it reaches a handler's callers as a generic `INTERNAL_SERVER_ERROR` * when thrown from the handler, so an upstream's response is not sent to them. @@ -174,15 +201,11 @@ export interface MalformedResponseErrorOptions extends ErrorOptions { * @see {@link https://orpc.dev/docs/rpc/link#malformed-responses | RPC Link - Malformed Responses} * @see {@link https://orpc.dev/docs/openapi/link#malformed-responses | OpenAPI Link - Malformed Responses} */ -export class MalformedResponseError extends Error { +export class MalformedResponseError extends ResponseError { override readonly name = 'MalformedResponseError' - response: StandardResponse - constructor(options: MalformedResponseErrorOptions) { - super(options.message ?? inferMalformedResponseMessage(options.response), options) - - this.response = options.response + super({ ...options, message: options.message ?? inferMalformedResponseMessage(options.response) }) } } diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts index c1b732f254..6be483192f 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -1,4 +1,4 @@ -import { MalformedResponseError, ORPCError } from '@orpc/client' +import { MalformedResponseError, ORPCError, ResponseError } from '@orpc/client' import { oc } from '@orpc/contract' import { openapi } from '../../meta' import { OpenAPISerializer } from '../../openapi-serializer' @@ -778,9 +778,9 @@ describe('openAPILinkCodec', () => { data: { id: '42' }, }) - const result = await codec.decodeResponse({ + const result: any = await codec.decodeResponse({ status: 404, - headers: {}, + headers: { 'x-request-id': 'abc' }, resolveBody: async () => error.toJSON(), }, ['ping'], { context: {} }) @@ -788,6 +788,11 @@ describe('openAPILinkCodec', () => { message: 'Missing', data: { id: '42' }, }) + + expect(result.error.cause).toBeInstanceOf(ResponseError) + expect(result.error.cause).not.toBeInstanceOf(MalformedResponseError) + expect(result.error.cause.message).toBe('Received a 404 response.') + expect(result.error.cause.response).toEqual({ status: 404, headers: { 'x-request-id': 'abc' }, body: error.toJSON() }) }) it('uses a custom error decoder when it returns a value', async () => { diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index 1c9f0b5562..face11622c 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -4,7 +4,7 @@ import type { AnyProcedureContract, RouterContract } from '@orpc/contract' import type { Promisable, Public, Value } from '@orpc/shared' import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standard-server/core' import type { OpenAPIMeta } from '../../meta' -import { createORPCErrorFromJson, isORPCErrorJson, MalformedResponseError } from '@orpc/client' +import { createORPCErrorFromJson, isORPCErrorJson, MalformedResponseError, ResponseError } from '@orpc/client' import { getRouterContract, ProcedureContract } from '@orpc/contract' import { unlazy } from '@orpc/server' import { isTypescriptObject, mergeHttpPath, pathToHttpPath, safeEncodeURIComponent, stringifyJSON, value } from '@orpc/shared' @@ -405,7 +405,12 @@ export class OpenAPILinkCodec implements StandardLinkCo } if (isORPCErrorJson(deserialized)) { - return { kind: 'error', error: createORPCErrorFromJson(deserialized) } + return { + kind: 'error', + error: createORPCErrorFromJson(deserialized, { + cause: new ResponseError({ response: { status: response.status, headers: response.headers, body } }), + }), + } } throw new MalformedResponseError({ response: { headers: response.headers, status: response.status, body } })