Skip to content

Commit b4c00e4

Browse files
authored
feat(rpc, openapi): improve malformed response handling in RPCLink and OpenAPILink (#1836)
Malformed responses, such as a proxy or gateway answering instead of the handler, now surface as an identifiable `MALFORMED_ORPC_RESPONSE` `ORPCError` with a meaningful message instead of a generic one. Its `cause` is a new `MalformedResponseError` carrying the typed resolved response, so users can detect this case the same way they detect validation errors. ## Changes - The error message is inferred from the response body (a string body, or a string `body.message`) or from the common error code matching the status; the previous behavior always used the generic default message. - A shared `createORPCErrorFromMalformedResponse(options)` helper in `@orpc/client` now backs both `RPCLinkCodec` and `OpenAPILinkCodec`, accepting the same options shape as `MalformedResponseError`. - Deserialization failures (`Invalid RPC response format.` / `Invalid OpenAPI response format.`) throw the same error with the raw resolved body attached, so the actual server payload is no longer lost (the RPC deserializer turns unknown JSON into `undefined`). - `OpenAPILinkCodec` resolves the body outside try/catch like `RPCLinkCodec`, so body-read failures propagate the original error instead of the `Cannot parse response body` wrapper. - New "Malformed Responses" sections in the RPCLink and OpenAPILink docs show identifying the case from a link interceptor. ## Breaking - The error code `MALFORMED_ORPC_ERROR_RESPONSE` is renamed to `MALFORMED_ORPC_RESPONSE`, since it now also covers success responses that fail to deserialize. - The `data` attached to the error is now the raw resolved body rather than the RPC/OpenAPI-deserialized value. ## Testing - Unit tests cover every message-inference path, the cause identity, and both codecs' malformed paths; all 901 client + openapi tests pass, along with `type:check`, `lint`, and `docs:validate`.
1 parent 77b2a40 commit b4c00e4

13 files changed

Lines changed: 275 additions & 66 deletions

File tree

apps/content/docs/openapi/link.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,24 @@ const link = new OpenAPILink(contract, {
269269
})
270270
```
271271

272+
## Malformed Responses
273+
274+
When `OpenAPILink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it produces an `ORPCError` with code `MALFORMED_ORPC_RESPONSE` and a message inferred from the body or status. Its `cause` is a `MalformedResponseError` carrying the resolved response:
275+
276+
```ts
277+
import { MalformedResponseError, ORPCError, onError } from '@orpc/client'
278+
279+
const link = new OpenAPILink(contract, {
280+
interceptors: [
281+
onError((error) => {
282+
if (error instanceof ORPCError && error.cause instanceof MalformedResponseError) {
283+
console.error('Malformed response:', error.cause.response.status, error.cause.response.body)
284+
}
285+
}),
286+
],
287+
})
288+
```
289+
272290
## Event Stream Options
273291

274292
Configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the server. Available options depend on the adapter. For example, the fetch adapter supports:

apps/content/docs/rpc/link.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,24 @@ const link = new RPCLink<ClientContext>({
294294
})
295295
```
296296

297+
## Malformed Responses
298+
299+
When `RPCLink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it produces an `ORPCError` with code `MALFORMED_ORPC_RESPONSE` and a message inferred from the body or status. Its `cause` is a `MalformedResponseError` carrying the resolved response:
300+
301+
```ts
302+
import { MalformedResponseError, ORPCError, onError } from '@orpc/client'
303+
304+
const link = new RPCLink({
305+
interceptors: [
306+
onError((error) => {
307+
if (error instanceof ORPCError && error.cause instanceof MalformedResponseError) {
308+
console.error('Malformed response:', error.cause.response.status, error.cause.response.body)
309+
}
310+
}),
311+
],
312+
})
313+
```
314+
297315
## Event Stream Options
298316

299317
Configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the server. Available options depend on the adapter. For example, the fetch adapter supports:

packages/client/src/adapters/standard/index.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,3 @@ export * from './link'
33
export * from './plugin'
44
export * from './rpc-link-codec'
55
export * from './transport'
6-
7-
export type {
8-
StandardBody,
9-
StandardBodyHint,
10-
StandardHeaders,
11-
StandardLazyRequest,
12-
StandardLazyResponse,
13-
StandardMethod,
14-
StandardRequest,
15-
StandardResponse,
16-
StandardUrl,
17-
} from '@standardserver/core'

packages/client/src/adapters/standard/rpc-link-codec.test.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { StandardUrl } from '@standardserver/core'
2-
import { ORPCError } from '../../error'
2+
import { MalformedResponseError, ORPCError } from '../../error'
33
import { RPCSerializer } from '../../rpc-serializer'
44
import { RPCLinkCodec } from './rpc-link-codec'
55

@@ -272,7 +272,7 @@ describe('rpcLinkCodec', () => {
272272
})
273273
})
274274

275-
it('wraps non-ORPCError error response with generic MALFORMED_ORPC_ERROR_RESPONSE ORPCError', async () => {
275+
it('wraps non-ORPCError error response with generic MALFORMED_ORPC_RESPONSE ORPCError', async () => {
276276
const serialized = serializer.serialize({ something: 'unexpected' })
277277

278278
const result = await codec.decodeResponse({
@@ -284,21 +284,46 @@ describe('rpcLinkCodec', () => {
284284
expect(result.kind).toBe('error')
285285
if (result.kind === 'error') {
286286
expect(result.error).toBeInstanceOf(ORPCError)
287-
expect(result.error.code).toBe('MALFORMED_ORPC_ERROR_RESPONSE')
287+
expect(result.error.code).toBe('MALFORMED_ORPC_RESPONSE')
288+
expect(result.error.message).toBe('Forbidden')
288289
expect(result.error.data).toEqual(expect.objectContaining({
289290
status: 403,
290291
headers: { 'x-header': 'value' },
291-
body: { something: 'unexpected' },
292+
body: serialized,
292293
}))
294+
expect(result.error.cause).toBeInstanceOf(MalformedResponseError)
295+
expect((result.error.cause as MalformedResponseError).response).toBe(result.error.data)
293296
}
294297
})
295298

296-
it('throws on invalid RPC response format', async () => {
297-
await expect(codec.decodeResponse({
299+
it('infers MALFORMED_ORPC_RESPONSE message from the response body', async () => {
300+
const result = await codec.decodeResponse({
301+
status: 400,
302+
headers: {},
303+
resolveBody: () => Promise.resolve({ message: 'upstream exploded' }),
304+
})
305+
306+
expect(result.kind).toBe('error')
307+
if (result.kind === 'error') {
308+
expect(result.error.code).toBe('MALFORMED_ORPC_RESPONSE')
309+
expect(result.error.message).toBe('upstream exploded')
310+
}
311+
})
312+
313+
it('throws MALFORMED_ORPC_RESPONSE on invalid RPC response format', async () => {
314+
const error: any = await codec.decodeResponse({
298315
status: 200,
299316
headers: {},
300317
resolveBody: () => Promise.resolve({ meta: 123 }),
301-
})).rejects.toThrow('Invalid RPC response format.')
318+
}).then(() => null, e => e)
319+
320+
expect(error).toBeInstanceOf(ORPCError)
321+
expect(error.code).toBe('MALFORMED_ORPC_RESPONSE')
322+
expect(error.message).toBe('Invalid RPC response format.')
323+
expect(error.data).toEqual({ status: 200, headers: {}, body: { meta: 123 } })
324+
expect(error.cause).toBeInstanceOf(MalformedResponseError)
325+
expect(error.cause.response).toBe(error.data)
326+
expect(error.cause.cause).toBeInstanceOf(Error)
302327
})
303328
})
304329
})

packages/client/src/adapters/standard/rpc-link-codec.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import type { Promisable, Value } from '@orpc/shared'
2-
import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardResponse, StandardUrl } from '@standardserver/core'
2+
import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUrl } from '@standardserver/core'
33
import type { ClientContext, ClientOptions } from '../../types'
44
import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '../standard'
55
import { isAsyncIteratorObject, pathToHttpPath, stringifyJSON, value } from '@orpc/shared'
66
import { mergeStandardHeaders, parseStandardUrl } from '@standardserver/core'
77
import { toStandardHeaders } from '@standardserver/fetch'
8-
import { ORPCError } from '../../error'
9-
import { createORPCErrorFromJson, isORPCErrorJson } from '../../error-utils'
8+
import { createORPCErrorFromJson, createORPCErrorFromMalformedResponse, isORPCErrorJson } from '../../error-utils'
109
import { RPCSerializer } from '../../rpc-serializer'
1110

1211
export interface RPCLinkCodecOptions<T extends ClientContext> {
@@ -132,7 +131,9 @@ export class RPCLinkCodec<T extends ClientContext> implements StandardLinkCodec<
132131
return this.serializer.deserialize(body)
133132
}
134133
catch (cause) {
135-
throw new Error('Invalid RPC response format.', {
134+
throw createORPCErrorFromMalformedResponse({
135+
message: 'Invalid RPC response format.',
136+
response: { status: response.status, headers: response.headers, body },
136137
cause,
137138
})
138139
}
@@ -145,9 +146,7 @@ export class RPCLinkCodec<T extends ClientContext> implements StandardLinkCodec<
145146

146147
return {
147148
kind: 'error',
148-
error: new ORPCError<'MALFORMED_ORPC_ERROR_RESPONSE', StandardResponse>('MALFORMED_ORPC_ERROR_RESPONSE', {
149-
data: { headers: response.headers, status: response.status, body: deserialized },
150-
}),
149+
error: createORPCErrorFromMalformedResponse({ response: { headers: response.headers, status: response.status, body } }),
151150
}
152151
}
153152

packages/client/src/error-utils.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { Writable } from '@orpc/shared'
2-
import { ORPCError } from './error'
2+
import { MalformedResponseError, ORPCError } from './error'
33
import {
44
cloneORPCError,
55
createORPCErrorFromJson,
6+
createORPCErrorFromMalformedResponse,
67
isInferableError,
78
isORPCErrorJson,
89
toORPCError,
@@ -180,6 +181,71 @@ describe('createORPCErrorFromJson', () => {
180181
})
181182
})
182183

184+
describe('createORPCErrorFromMalformedResponse', () => {
185+
it('creates a MALFORMED_ORPC_RESPONSE error with the response as data and a MalformedResponseError cause', () => {
186+
const response = { status: 500, headers: { 'x-header': 'value' }, body: { something: 'unexpected' } }
187+
const error = createORPCErrorFromMalformedResponse({ response })
188+
189+
expect(error).toBeInstanceOf(ORPCError)
190+
expect(error.code).toBe('MALFORMED_ORPC_RESPONSE')
191+
expect(error.defined).toBe(false)
192+
expect(error.data).toBe(response)
193+
expect(error.cause).toBeInstanceOf(MalformedResponseError)
194+
expect((error.cause as MalformedResponseError).name).toBe('MalformedResponseError')
195+
expect((error.cause as MalformedResponseError).response).toBe(response)
196+
expect((error.cause as MalformedResponseError).message).toBe(error.message)
197+
})
198+
199+
it('supports overriding the message and forwarding a cause to the MalformedResponseError', () => {
200+
const cause = new Error('deserialize failed')
201+
const error = createORPCErrorFromMalformedResponse({
202+
message: 'Invalid RPC response format.',
203+
response: { status: 200, headers: {}, body: 'not rpc format' },
204+
cause,
205+
})
206+
207+
expect(error.message).toBe('Invalid RPC response format.')
208+
expect((error.cause as MalformedResponseError).message).toBe('Invalid RPC response format.')
209+
expect((error.cause as MalformedResponseError).cause).toBe(cause)
210+
})
211+
212+
it('infers message from a string body', () => {
213+
const error = createORPCErrorFromMalformedResponse({ response: { status: 500, headers: {}, body: 'upstream exploded' } })
214+
215+
expect(error.message).toBe('upstream exploded')
216+
})
217+
218+
it('infers message from body.message', () => {
219+
const error = createORPCErrorFromMalformedResponse({ response: { status: 500, headers: {}, body: { message: 'upstream exploded', detail: 'ignored' } } })
220+
221+
expect(error.message).toBe('upstream exploded')
222+
})
223+
224+
it('infers message from a common error code matching the status', () => {
225+
expect(createORPCErrorFromMalformedResponse({ response: { status: 404, headers: {}, body: { detail: 'no message here' } } }).message).toBe('Not Found')
226+
expect(createORPCErrorFromMalformedResponse({ response: { status: 503, headers: {}, body: undefined } }).message).toBe('Service Unavailable')
227+
})
228+
229+
it('ignores bodies longer than 256 characters', () => {
230+
const long = 'x'.repeat(257)
231+
232+
expect(createORPCErrorFromMalformedResponse({ response: { status: 502, headers: {}, body: long } }).message).toBe('Bad Gateway')
233+
expect(createORPCErrorFromMalformedResponse({ response: { status: 502, headers: {}, body: { message: long } } }).message).toBe('Bad Gateway')
234+
})
235+
236+
it.each([
237+
['empty string body', ''],
238+
['empty body.message', { message: '' }],
239+
['oversized string body', 'x'.repeat(257)],
240+
['non-string body.message', { message: 42 }],
241+
['non-object body', 42],
242+
])('falls back to the default message when the status is uncommon and the body has no message (%s)', (_, body) => {
243+
const error = createORPCErrorFromMalformedResponse({ response: { status: 599, headers: {}, body } })
244+
245+
expect(error.message).toBe('Malformed Orpc Response')
246+
})
247+
})
248+
183249
describe('cloneORPCError', () => {
184250
it('creates a clone of ORPCError', () => {
185251
const original = new ORPCError('BAD_REQUEST', {

packages/client/src/error-utils.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Writable } from '@orpc/shared'
2-
import type { AnyORPCError, ORPCErrorCode, ORPCErrorJSON } from './error'
2+
import type { StandardResponse } from '@standardserver/core'
3+
import type { AnyORPCError, MalformedResponseErrorOptions, ORPCErrorCode, ORPCErrorJSON } from './error'
34
import { isPlainObject } from '@orpc/shared'
4-
import { ORPCError } from './error'
5+
import { COMMON_ERROR_STATUS_MAP, MalformedResponseError, ORPCError } from './error'
56

67
/**
78
* Checks if an error is an `ORPCError` whose type is inferable at the TypeScript level,
@@ -54,6 +55,51 @@ export function createORPCErrorFromJson<TCode extends ORPCErrorCode, TData>(
5455
return error
5556
}
5657

58+
/**
59+
* Creates the `MALFORMED_ORPC_RESPONSE` `ORPCError` used when a response
60+
* does not follow the expected oRPC format. Unless overridden via `options.message`,
61+
* the message is inferred from the response body or status. The `cause` is a
62+
* `MalformedResponseError` carrying the resolved response.
63+
*
64+
* @see {@link https://orpc.dev/docs/rpc/link#malformed-responses | RPC Link - Malformed Responses}
65+
* @see {@link https://orpc.dev/docs/openapi/link#malformed-responses | OpenAPI Link - Malformed Responses}
66+
*/
67+
export function createORPCErrorFromMalformedResponse(options: MalformedResponseErrorOptions): ORPCError<'MALFORMED_ORPC_RESPONSE', StandardResponse> {
68+
const error = new ORPCError('MALFORMED_ORPC_RESPONSE', {
69+
message: options.message ?? inferMalformedResponseMessage(options.response),
70+
data: options.response,
71+
})
72+
73+
error.cause = new MalformedResponseError({ ...options, message: error.message })
74+
75+
return error
76+
}
77+
78+
/**
79+
* Bounds for using a body string as the error message,
80+
* ignoring empty and unreasonably long values.
81+
*/
82+
const INFERRED_MESSAGE_MIN_LENGTH = 1
83+
const INFERRED_MESSAGE_MAX_LENGTH = 256
84+
85+
function isInferableMessage(text: string): boolean {
86+
return text.length >= INFERRED_MESSAGE_MIN_LENGTH && text.length <= INFERRED_MESSAGE_MAX_LENGTH
87+
}
88+
89+
function inferMalformedResponseMessage(response: StandardResponse): string | undefined {
90+
if (typeof response.body === 'string' && isInferableMessage(response.body)) {
91+
return response.body
92+
}
93+
94+
if (isPlainObject(response.body) && typeof response.body.message === 'string' && isInferableMessage(response.body.message)) {
95+
return response.body.message
96+
}
97+
98+
const commonCode = Object.entries(COMMON_ERROR_STATUS_MAP).find(([, status]) => status === response.status)?.[0]
99+
100+
return commonCode?.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ')
101+
}
102+
57103
/**
58104
* Clones an `ORPCError` while preserving its prototype chain, so instances of
59105
* `ORPCError` subclasses remain `instanceof` their class.

packages/client/src/error.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { MaybeOptionalOptions, Registry } from '@orpc/shared'
2+
import type { StandardResponse } from '@standardserver/core'
23
import { getConstructors, resolveMaybeOptionalOptions } from '@orpc/shared'
34

45
/**
@@ -161,3 +162,28 @@ export interface ORPCErrorJSON<TCode extends string, TData> extends Pick<ORPCErr
161162

162163
export type AnyORPCError = ORPCError<any, any>
163164
export type AnyORPCErrorJSON = ORPCErrorJSON<any, any>
165+
166+
export interface MalformedResponseErrorOptions extends ErrorOptions {
167+
message?: string
168+
response: StandardResponse
169+
}
170+
171+
/**
172+
* Error indicating a response does not follow the expected oRPC format, carrying
173+
* the resolved response. Found as the `cause` of a `MALFORMED_ORPC_RESPONSE`
174+
* `ORPCError`.
175+
*
176+
* @see {@link https://orpc.dev/docs/rpc/link#malformed-responses | RPC Link - Malformed Responses}
177+
* @see {@link https://orpc.dev/docs/openapi/link#malformed-responses | OpenAPI Link - Malformed Responses}
178+
*/
179+
export class MalformedResponseError extends Error {
180+
override readonly name = 'MalformedResponseError'
181+
182+
response: StandardResponse
183+
184+
constructor(options: MalformedResponseErrorOptions) {
185+
super(options.message, options)
186+
187+
this.response = options.response
188+
}
189+
}

packages/client/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ export type {
7070

7171
export type {
7272
EventMeta,
73+
StandardBody,
74+
StandardBodyHint,
75+
StandardHeaders,
76+
StandardLazyRequest,
77+
StandardLazyResponse,
78+
StandardMethod,
79+
StandardRequest,
80+
StandardResponse,
81+
StandardUrl,
7382
} from '@standardserver/core'
7483

7584
export {

0 commit comments

Comments
 (0)