Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/content/docs/openapi/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 19 additions & 1 deletion apps/content/docs/rpc/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,27 @@ const link = new RPCLink<ClientContext>({
})
```

## 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'
Expand Down
12 changes: 9 additions & 3 deletions packages/client/src/adapters/standard/rpc-link-codec.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -291,16 +291,22 @@ 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),
})

expect(result).toEqual({
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 () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/adapters/standard/rpc-link-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -142,7 +142,12 @@ export class RPCLinkCodec<T extends ClientContext> 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 } })
Expand Down
26 changes: 25 additions & 1 deletion packages/client/src/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NullProtoObj } from '@orpc/shared'
import { MalformedResponseError, ORPCError } from './error'
import { MalformedResponseError, ORPCError, ResponseError } from './error'

describe('oRPCError', () => {
it('works', () => {
Expand Down Expand Up @@ -111,13 +111,37 @@ 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' } }
const cause = new Error('deserialize failed')
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.')
Expand Down
45 changes: 34 additions & 11 deletions packages/client/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,35 +154,58 @@ export interface ORPCErrorJSON<TCode extends string, TData> extends Pick<ORPCErr
export type AnyORPCError = ORPCError<any, any>
export type AnyORPCErrorJSON = ORPCErrorJSON<any, any>

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.
*
* @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) })
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -778,16 +778,21 @@ 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: {} })

expectORPCErrorResult(result, 'NOT_FOUND', {
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 () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-link-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -405,7 +405,12 @@ export class OpenAPILinkCodec<T extends ClientContext> 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 } })
Expand Down
Loading