@atproto/lex-client 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { IssueInvalidType, LexValidationError, l } from '@atproto/lex-schema'
3
+ import {
4
+ XrpcAuthenticationError,
5
+ XrpcFetchError,
6
+ XrpcInternalError,
7
+ XrpcInvalidResponseError,
8
+ XrpcResponseError,
9
+ XrpcUpstreamError,
10
+ asXrpcFailure,
11
+ } from './errors.js'
12
+
13
+ // Minimal method fixture
14
+ const testQuery = l.query(
15
+ 'io.example.test',
16
+ l.params(),
17
+ l.jsonPayload({ value: l.string() }),
18
+ ['TestError', 'AnotherError'],
19
+ )
20
+
21
+ const testQueryNoErrors = l.query(
22
+ 'io.example.noErrors',
23
+ l.params(),
24
+ l.jsonPayload({ value: l.string() }),
25
+ )
26
+
27
+ // ============================================================================
28
+ // XrpcResponseError
29
+ // ============================================================================
30
+
31
+ describe(XrpcResponseError, () => {
32
+ function createResponseError(
33
+ status: number,
34
+ errorCode: string,
35
+ message?: string,
36
+ ) {
37
+ const response = new Response(null, { status })
38
+ return new XrpcResponseError(testQuery, response, {
39
+ encoding: 'application/json',
40
+ body: { error: errorCode, message },
41
+ })
42
+ }
43
+
44
+ it('exposes status from the response', () => {
45
+ const err = createResponseError(404, 'NotFound')
46
+ expect(err.reason).toBe(err)
47
+ expect(err.status).toBe(404)
48
+ })
49
+
50
+ it('exposes headers from the response', () => {
51
+ const response = new Response(null, {
52
+ status: 400,
53
+ headers: { 'X-Test': 'value' },
54
+ })
55
+ const err = new XrpcResponseError(testQuery, response, {
56
+ encoding: 'application/json',
57
+ body: { error: 'TestError' },
58
+ })
59
+ expect(err.reason).toBe(err)
60
+ expect(err.headers.get('X-Test')).toBe('value')
61
+ })
62
+
63
+ it('exposes body from the payload', () => {
64
+ const err = createResponseError(400, 'TestError', 'details')
65
+ expect(err.body).toEqual({ error: 'TestError', message: 'details' })
66
+ })
67
+
68
+ describe('toDownstreamError', () => {
69
+ it('returns 502 for upstream 500 errors', () => {
70
+ const err = createResponseError(
71
+ 500,
72
+ 'InternalServerError',
73
+ 'Upstream crashed',
74
+ )
75
+ const downstream = err.toDownstreamError()
76
+
77
+ expect(downstream.status).toBe(502)
78
+ expect(downstream.body).toEqual({
79
+ error: 'InternalServerError',
80
+ message: 'Upstream crashed',
81
+ })
82
+ })
83
+
84
+ it('preserves original status for non-500 5xx errors', () => {
85
+ const err = createResponseError(503, 'ServiceUnavailable', 'Try later')
86
+ const downstream = err.toDownstreamError()
87
+
88
+ expect(downstream.status).toBe(503)
89
+ expect(downstream.body).toEqual({
90
+ error: 'ServiceUnavailable',
91
+ message: 'Try later',
92
+ })
93
+ })
94
+
95
+ it('preserves original status for 4xx errors', () => {
96
+ const err = createResponseError(404, 'NotFound', 'Record not found')
97
+ const downstream = err.toDownstreamError()
98
+
99
+ expect(downstream.status).toBe(404)
100
+ expect(downstream.body).toEqual({
101
+ error: 'NotFound',
102
+ message: 'Record not found',
103
+ })
104
+ })
105
+ })
106
+
107
+ describe('toJSON', () => {
108
+ it('returns the payload body', () => {
109
+ const err = createResponseError(400, 'TestError', 'message')
110
+ expect(err.toJSON()).toEqual({ error: 'TestError', message: 'message' })
111
+ })
112
+ })
113
+
114
+ describe('matchesSchemaErrors', () => {
115
+ it('returns true when error matches method declared errors', () => {
116
+ const err = createResponseError(400, 'TestError')
117
+ expect(err.matchesSchemaErrors()).toBe(true)
118
+ })
119
+
120
+ it('returns false for undeclared error codes', () => {
121
+ const err = createResponseError(400, 'UnknownError')
122
+ expect(err.matchesSchemaErrors()).toBe(false)
123
+ })
124
+
125
+ it('returns false when method has no declared errors', () => {
126
+ const response = new Response(null, { status: 400 })
127
+ const err = new XrpcResponseError(testQueryNoErrors, response, {
128
+ encoding: 'application/json',
129
+ body: { error: 'SomeError' },
130
+ })
131
+ expect(err.matchesSchemaErrors()).toBe(false)
132
+ })
133
+ })
134
+
135
+ describe('shouldRetry', () => {
136
+ it('returns true for retryable status codes', () => {
137
+ expect(createResponseError(429, 'RateLimit').shouldRetry()).toBe(true)
138
+ expect(createResponseError(500, 'Internal').shouldRetry()).toBe(true)
139
+ expect(createResponseError(502, 'BadGateway').shouldRetry()).toBe(true)
140
+ expect(createResponseError(503, 'Unavailable').shouldRetry()).toBe(true)
141
+ })
142
+
143
+ it('returns false for non-retryable status codes', () => {
144
+ expect(createResponseError(400, 'BadRequest').shouldRetry()).toBe(false)
145
+ expect(createResponseError(401, 'Unauthorized').shouldRetry()).toBe(false)
146
+ expect(createResponseError(404, 'NotFound').shouldRetry()).toBe(false)
147
+ })
148
+ })
149
+ })
150
+
151
+ // ============================================================================
152
+ // XrpcAuthenticationError
153
+ // ============================================================================
154
+
155
+ describe(XrpcAuthenticationError, () => {
156
+ it('is never retryable', () => {
157
+ const response = new Response(null, { status: 401 })
158
+ const err = new XrpcAuthenticationError(testQuery, response, {
159
+ encoding: 'application/json',
160
+ body: { error: 'AuthenticationRequired' },
161
+ })
162
+ expect(err.shouldRetry()).toBe(false)
163
+ })
164
+
165
+ it('parses WWW-Authenticate header', () => {
166
+ const response = new Response(null, {
167
+ status: 401,
168
+ headers: {
169
+ 'WWW-Authenticate': 'Bearer realm="api", error="InvalidToken"',
170
+ },
171
+ })
172
+ const err = new XrpcAuthenticationError(testQuery, response, {
173
+ encoding: 'application/json',
174
+ body: { error: 'AuthenticationRequired' },
175
+ })
176
+ expect(err.reason).toBe(err)
177
+ expect(err.wwwAuthenticate).toHaveProperty('Bearer')
178
+ })
179
+
180
+ it('returns empty object when no WWW-Authenticate header', () => {
181
+ const response = new Response(null, { status: 401 })
182
+ const err = new XrpcAuthenticationError(testQuery, response, {
183
+ encoding: 'application/json',
184
+ body: { error: 'AuthenticationRequired' },
185
+ })
186
+ expect(err.wwwAuthenticate).toEqual({})
187
+ })
188
+
189
+ it('toDownstreamError always returns 401', () => {
190
+ const response = new Response(null, { status: 401 })
191
+ const err = new XrpcAuthenticationError(testQuery, response, {
192
+ encoding: 'application/json',
193
+ body: { error: 'AuthenticationRequired', message: 'No token' },
194
+ })
195
+ const downstream = err.toDownstreamError()
196
+
197
+ expect(downstream.status).toBe(401)
198
+ expect(downstream.body).toEqual({
199
+ error: 'AuthenticationRequired',
200
+ message: 'No token',
201
+ })
202
+ })
203
+ })
204
+
205
+ // ============================================================================
206
+ // XrpcUpstreamError
207
+ // ============================================================================
208
+
209
+ describe(XrpcUpstreamError, () => {
210
+ it('has error code UpstreamFailure', () => {
211
+ const response = new Response(null, { status: 200 })
212
+ const err = new XrpcUpstreamError(testQuery, response)
213
+ expect(err.reason).toBe(err)
214
+ expect(err.error).toBe('UpstreamFailure')
215
+ })
216
+
217
+ it('toDownstreamError returns 502', () => {
218
+ const response = new Response(null, { status: 200 })
219
+ const err = new XrpcUpstreamError(testQuery, response)
220
+ const downstream = err.toDownstreamError()
221
+ expect(downstream.status).toBe(502)
222
+ })
223
+
224
+ it('shouldRetry is true for retryable status codes', () => {
225
+ const response = new Response(null, { status: 502 })
226
+ const err = new XrpcUpstreamError(testQuery, response)
227
+ expect(err.shouldRetry()).toBe(true)
228
+ })
229
+
230
+ it('shouldRetry is false for non-retryable status codes', () => {
231
+ const response = new Response(null, { status: 200 })
232
+ const err = new XrpcUpstreamError(testQuery, response)
233
+ expect(err.shouldRetry()).toBe(false)
234
+ })
235
+ })
236
+
237
+ // ============================================================================
238
+ // XrpcInvalidResponseError
239
+ // ============================================================================
240
+
241
+ describe(XrpcInvalidResponseError, () => {
242
+ it('extends XrpcUpstreamError', () => {
243
+ const response = new Response(null, { status: 200 })
244
+ const validationError = new LexValidationError([
245
+ new IssueInvalidType([], 42, ['string']),
246
+ ])
247
+ const err = new XrpcInvalidResponseError(
248
+ testQuery,
249
+ response,
250
+ { encoding: 'application/json', body: { value: 42 } },
251
+ validationError,
252
+ )
253
+
254
+ expect(err).toBeInstanceOf(XrpcUpstreamError)
255
+ expect(err.reason).toBe(err)
256
+ expect(err.error).toBe('UpstreamFailure')
257
+ expect(err.cause).toBe(validationError)
258
+ })
259
+
260
+ it('includes validation error message', () => {
261
+ const validationError = new LexValidationError([
262
+ new IssueInvalidType([], 42, ['string']),
263
+ ])
264
+ const err = new XrpcInvalidResponseError(
265
+ testQuery,
266
+ new Response(null, { status: 200 }),
267
+ { encoding: 'application/json', body: { value: 42 } },
268
+ validationError,
269
+ )
270
+
271
+ expect(err.message).toContain('Invalid response:')
272
+ expect(err.message).toContain(validationError.message)
273
+ })
274
+
275
+ it('toDownstreamError returns 502', () => {
276
+ const validationError = new LexValidationError([
277
+ new IssueInvalidType([], 42, ['string']),
278
+ ])
279
+ const err = new XrpcInvalidResponseError(
280
+ testQuery,
281
+ new Response(null, { status: 200 }),
282
+ { encoding: 'application/json', body: { value: 42 } },
283
+ validationError,
284
+ )
285
+ const downstream = err.toDownstreamError()
286
+ expect(downstream.status).toBe(502)
287
+ })
288
+ })
289
+
290
+ // ============================================================================
291
+ // XrpcInternalError
292
+ // ============================================================================
293
+
294
+ describe(XrpcInternalError, () => {
295
+ it('has error code InternalServerError', () => {
296
+ const err = new XrpcInternalError(testQuery)
297
+ expect(err.reason).toBe(err)
298
+ expect(err.error).toBe('InternalServerError')
299
+ })
300
+
301
+ it('toJSON does not expose internal details', () => {
302
+ const err = new XrpcInternalError(
303
+ testQuery,
304
+ 'Secret database connection string leaked',
305
+ )
306
+ const json = err.toJSON()
307
+
308
+ expect(json.error).toBe('InternalServerError')
309
+ expect(json.message).toBe('Internal Server Error')
310
+ expect(json.message).not.toContain('Secret')
311
+ })
312
+
313
+ it('toDownstreamError returns 500', () => {
314
+ const err = new XrpcInternalError(testQuery, 'internal details')
315
+ const downstream = err.toDownstreamError()
316
+
317
+ expect(downstream.status).toBe(500)
318
+ expect(downstream.body.error).toBe('InternalServerError')
319
+ expect(downstream.body.message).toBe('Internal Server Error')
320
+ })
321
+
322
+ it('is not retryable', () => {
323
+ const err = new XrpcInternalError(testQuery, 'something broke')
324
+ expect(err.shouldRetry()).toBe(false)
325
+ })
326
+ })
327
+
328
+ // ============================================================================
329
+ // XrpcFetchError
330
+ // ============================================================================
331
+
332
+ describe(XrpcFetchError, () => {
333
+ it('extends XrpcInternalError', () => {
334
+ const err = new XrpcFetchError(testQuery, new TypeError('fetch failed'))
335
+ expect(err).toBeInstanceOf(XrpcInternalError)
336
+ expect(err.error).toBe('InternalServerError')
337
+ })
338
+
339
+ it('uses cause message when cause is an Error', () => {
340
+ const cause = new TypeError('Failed to fetch')
341
+ const err = new XrpcFetchError(testQuery, cause)
342
+ expect(err.message).toBe('Unexpected fetchHandler() error: Failed to fetch')
343
+ expect(err.cause).toBe(cause)
344
+ })
345
+
346
+ it('uses fallback message when cause is not an Error', () => {
347
+ const err = new XrpcFetchError(testQuery, 'string cause')
348
+ expect(err.message).toBe('Unexpected fetchHandler() error: string cause')
349
+ expect(err.cause).toBe('string cause')
350
+ })
351
+
352
+ it('is retryable', () => {
353
+ const err = new XrpcFetchError(testQuery, new Error('network timeout'))
354
+ expect(err.shouldRetry()).toBe(true)
355
+ })
356
+
357
+ it('toJSON does not expose internal details', () => {
358
+ const err = new XrpcFetchError(
359
+ testQuery,
360
+ new Error('ECONNREFUSED 10.0.0.1:443'),
361
+ )
362
+ const json = err.toJSON()
363
+
364
+ expect(json.error).toBe('InternalServerError')
365
+ expect(json.message).toBe('Failed to perform upstream request')
366
+ expect(json.message).not.toContain('ECONNREFUSED')
367
+ })
368
+
369
+ it('toDownstreamError returns 502', () => {
370
+ const err = new XrpcFetchError(testQuery, new Error('DNS lookup failed'))
371
+ const downstream = err.toDownstreamError()
372
+
373
+ expect(downstream.status).toBe(502)
374
+ expect(downstream.body.error).toBe('InternalServerError')
375
+ expect(downstream.body.message).toBe('Failed to perform upstream request')
376
+ })
377
+ })
378
+
379
+ // ============================================================================
380
+ // asXrpcFailure
381
+ // ============================================================================
382
+
383
+ describe('asXrpcFailure', () => {
384
+ it('returns existing XrpcResponseError for the same method', () => {
385
+ const response = new Response(null, { status: 400 })
386
+ const err = new XrpcResponseError(testQuery, response, {
387
+ encoding: 'application/json',
388
+ body: { error: 'TestError' },
389
+ })
390
+ expect(asXrpcFailure(testQuery, err)).toBe(err)
391
+ })
392
+
393
+ it('wraps unknown errors in XrpcInternalError', () => {
394
+ const err = new TypeError('fetch failed')
395
+ const failure = asXrpcFailure(testQuery, err)
396
+
397
+ expect(failure).toBeInstanceOf(XrpcInternalError)
398
+ expect(failure.cause).toBe(err)
399
+ })
400
+
401
+ it('wraps XrpcError for a different method in XrpcInternalError', () => {
402
+ const otherQuery = l.query(
403
+ 'io.example.other',
404
+ l.params(),
405
+ l.payload('application/json', l.object({ value: l.string() })),
406
+ )
407
+ const response = new Response(null, { status: 400 })
408
+ const err = new XrpcResponseError(otherQuery, response, {
409
+ encoding: 'application/json',
410
+ body: { error: 'TestError' },
411
+ })
412
+ const failure = asXrpcFailure(testQuery, err)
413
+ expect(failure).toBeInstanceOf(XrpcInternalError)
414
+ })
415
+ })
package/src/errors.ts CHANGED
@@ -1,17 +1,27 @@
1
1
  import { LexError, LexErrorCode, LexErrorData } from '@atproto/lex-data'
2
2
  import {
3
3
  InferMethodError,
4
+ LexValidationError,
4
5
  Procedure,
5
6
  Query,
6
7
  ResultFailure,
7
8
  lexErrorDataSchema,
8
9
  } from '@atproto/lex-schema'
10
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
11
+ import { Agent } from './agent.js'
9
12
  import { XrpcResponsePayload } from './util.js'
10
13
  import {
11
14
  WWWAuthenticate,
12
15
  parseWWWAuthenticateHeader,
13
16
  } from './www-authenticate.js'
14
17
 
18
+ export type DownstreamError<N extends LexErrorCode = LexErrorCode> = {
19
+ status: number
20
+ headers?: Headers
21
+ encoding?: 'application/json'
22
+ body: LexErrorData<N>
23
+ }
24
+
15
25
  /**
16
26
  * HTTP status codes that indicate a transient error that may succeed on retry.
17
27
  *
@@ -115,7 +125,9 @@ export abstract class XrpcError<
115
125
  */
116
126
  abstract shouldRetry(): boolean
117
127
 
118
- matchesSchema(): this is XrpcError<M, InferMethodError<M>> {
128
+ abstract toDownstreamError(): DownstreamError
129
+
130
+ matchesSchemaErrors(): this is XrpcError<M, InferMethodError<M>> {
119
131
  return this.method.errors?.includes(this.error) ?? false
120
132
  }
121
133
  }
@@ -127,7 +139,7 @@ export abstract class XrpcError<
127
139
  * a non-2xx status with a valid JSON error payload containing `error` and
128
140
  * optional `message` fields.
129
141
  *
130
- * Use {@link matchesSchema} to check if the error matches the method's declared
142
+ * Use {@link matchesSchemaErrors} to check if the error matches the method's declared
131
143
  * error types for type-safe error handling.
132
144
  *
133
145
  * @typeParam M - The XRPC method type
@@ -168,25 +180,32 @@ export class XrpcResponseError<
168
180
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
169
181
  }
170
182
 
171
- override toJSON() {
183
+ override toJSON(): LexErrorData<N> {
172
184
  return this.payload.body
173
185
  }
174
186
 
175
- override toResponse(): Response {
176
- // Re-expose schema-valid errors as-is to downstream clients
177
- if (this.matchesSchema()) {
178
- const status = this.response.status >= 500 ? 502 : this.response.status
179
- return Response.json(this.toJSON(), { status })
187
+ override toDownstreamError(): DownstreamError {
188
+ // If the upstream server returned a 5xx error, we want to return a 502 Bad
189
+ // Gateway to downstream clients, as the issue is with the upstream server,
190
+ // not us. We still return the original error code and message in the body
191
+ // for transparency, but we do not want to expose internal server errors
192
+ // from the upstream server as-is to downstream clients.
193
+ return {
194
+ status: this.response.status === 500 ? 502 : this.status,
195
+ headers: stripHopByHopHeaders(this.headers),
196
+ body: this.toJSON(),
180
197
  }
198
+ }
199
+
200
+ get status(): number {
201
+ return this.response.status
202
+ }
181
203
 
182
- return this.response.status >= 500
183
- ? // The upstream server had an error, return a generic upstream failure
184
- Response.json({ error: 'UpstreamFailure' }, { status: 502 })
185
- : // If the error is on our side, return a generic internal server error
186
- Response.json({ error: 'InternalServerError' }, { status: 500 })
204
+ get headers(): Headers {
205
+ return this.response.headers
187
206
  }
188
207
 
189
- get body(): LexErrorData {
208
+ get body(): LexErrorData<N> {
190
209
  return this.payload.body
191
210
  }
192
211
  }
@@ -240,6 +259,14 @@ export class XrpcAuthenticationError<
240
259
  this.response.headers.get('www-authenticate'),
241
260
  ) ?? {})
242
261
  }
262
+
263
+ override toDownstreamError(): DownstreamError {
264
+ return {
265
+ status: 401,
266
+ headers: stripHopByHopHeaders(this.headers),
267
+ body: this.toJSON(),
268
+ }
269
+ }
243
270
  }
244
271
 
245
272
  /**
@@ -280,23 +307,53 @@ export class XrpcUpstreamError<
280
307
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
281
308
  }
282
309
 
283
- override toResponse(): Response {
284
- return Response.json(this.toJSON(), { status: 502 })
310
+ override toDownstreamError(): DownstreamError {
311
+ return { status: 502, body: this.toJSON() }
285
312
  }
286
313
  }
287
314
 
288
315
  /**
289
- * Error class for internal/client-side errors during XRPC requests.
316
+ * Error class for invalid XRPC responses that fail schema validation.
290
317
  *
291
- * This represents errors that occur before or during the request that are not
292
- * server responses, such as:
293
- * - Network errors (connection refused, DNS failure)
294
- * - Request timeouts
295
- * - Request aborted via AbortSignal
296
- * - Invalid request construction
318
+ * This is a specific type of {@link XrpcUpstreamError} that indicates the
319
+ * upstream server returned a response that was structurally valid but did not
320
+ * conform to the expected schema for the method. This likely indicates a
321
+ * mismatch between client and server versions or an issue with the server's
322
+ * XRPC implementation.
323
+ *
324
+ * @typeParam M - The XRPC method type
325
+ */
326
+ export class XrpcInvalidResponseError<
327
+ M extends Procedure | Query = Procedure | Query,
328
+ > extends XrpcUpstreamError<M> {
329
+ name = 'XrpcInvalidResponseError'
330
+
331
+ constructor(
332
+ method: M,
333
+ response: Response,
334
+ payload: XrpcResponsePayload,
335
+ readonly cause: LexValidationError,
336
+ ) {
337
+ super(method, response, payload, `Invalid response: ${cause.message}`, {
338
+ cause,
339
+ })
340
+ }
341
+
342
+ override toDownstreamError(): DownstreamError {
343
+ // @NOTE This could be reflected as both a 500 ("we" are at fault) and 502
344
+ // ("they" are at fault). We are using 502 here to allow downstream clients
345
+ // to determine that the issue lies at the interface between us and the
346
+ // upstream server, rather than an issue with our internal processing.
347
+ return { status: 502, body: this.toJSON() }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Error class for unexpected internal/client-side errors during XRPC requests.
297
353
  *
298
- * The error code is always 'InternalServerError' and these errors are
299
- * optimistically considered retryable.
354
+ * The error code is always 'InternalServerError' and these errors not
355
+ * considered retryable as they stem from unforeseen issues in the
356
+ * implementation.
300
357
  *
301
358
  * @typeParam M - The XRPC method type
302
359
  */
@@ -318,17 +375,60 @@ export class XrpcInternalError<
318
375
  return this
319
376
  }
320
377
 
321
- override shouldRetry(): true {
322
- // Ideally, we would inspect the reason to determine if it's retryable
323
- // (by detecting network errors, timeouts, etc.). Since these cases are
324
- // highly platform-dependent, we optimistically assume all internal
325
- // errors are retryable.
378
+ override shouldRetry(): boolean {
379
+ return false
380
+ }
381
+
382
+ override toJSON(): LexErrorData {
383
+ // @NOTE Do not expose internal error details to downstream clients
384
+ return { error: this.error, message: 'Internal Server Error' }
385
+ }
386
+
387
+ override toDownstreamError(): DownstreamError {
388
+ return { status: 500, body: this.toJSON() }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Special case of XrpcInternalError that specifically represents errors thrown
394
+ * by {@link Agent.fetchHandler} during the XRPC request. This includes:
395
+ * - Network errors (connection refused, DNS failure)
396
+ * - Request timeouts
397
+ * - Request aborted via AbortSignal
398
+ *
399
+ * These errors are optimistically considered retryable, as many fetch errors
400
+ * are transient and may succeed on retry.
401
+ */
402
+ export class XrpcFetchError<
403
+ M extends Procedure | Query = Procedure | Query,
404
+ > extends XrpcInternalError<M> {
405
+ name = 'XrpcFetchError'
406
+
407
+ constructor(method: M, cause: unknown) {
408
+ const message = cause instanceof Error ? cause.message : String(cause)
409
+ super(method, `Unexpected fetchHandler() error: ${message}`, { cause })
410
+ }
411
+
412
+ override shouldRetry(): boolean {
413
+ // Ideally, we would inspect the reason to determine if it's retryable (by
414
+ // detecting network errors, timeouts, etc.). Since these cases are highly
415
+ // platform-dependent, we optimistically assume all fetch errors are
416
+ // transient and retryable.
326
417
  return true
327
418
  }
328
419
 
329
- override toResponse(): Response {
330
- // Do not expose internal error details to downstream clients
331
- return Response.json({ error: this.error }, { status: 500 })
420
+ override toJSON(): LexErrorData {
421
+ // @NOTE Do not expose internal error details to downstream clients
422
+ return { error: this.error, message: 'Failed to perform upstream request' }
423
+ }
424
+
425
+ override toDownstreamError(): DownstreamError {
426
+ // While it might technically be a 500 error, we use 502 Bad Gateway here to
427
+ // indicate that the error occurred while communicating with the upstream
428
+ // server, allowing downstream clients to distinguish between errors in our
429
+ // internal processing (500) and errors in the upstream server or network
430
+ // (502).
431
+ return { status: 502, body: this.toJSON() }
332
432
  }
333
433
  }
334
434
 
@@ -393,3 +493,39 @@ export function asXrpcFailure<M extends Procedure | Query>(
393
493
 
394
494
  return new XrpcInternalError(method, undefined, { cause })
395
495
  }
496
+
497
+ const HOP_BY_HOP_HEADERS = new Set([
498
+ 'connection',
499
+ 'keep-alive',
500
+ 'proxy-authenticate',
501
+ 'proxy-authorization',
502
+ 'te',
503
+ 'trailer',
504
+ 'transfer-encoding',
505
+ 'upgrade',
506
+ ])
507
+
508
+ function stripHopByHopHeaders(headers: Headers): Headers {
509
+ const result = new Headers(headers)
510
+
511
+ // Remove statically known hop-by-hop headers
512
+ for (const name of HOP_BY_HOP_HEADERS) {
513
+ result.delete(name)
514
+ }
515
+
516
+ // Remove headers listed in the "Connection" header
517
+ const connection = headers.get('connection')
518
+ if (connection) {
519
+ for (const name of connection.split(',')) {
520
+ result.delete(name.trim())
521
+ }
522
+ }
523
+
524
+ // These are not actually hop-by-hop headers, but we remove them because the
525
+ // upstream payload gets parsed and re-serialized, so content length and
526
+ // encoding may no longer be accurate.
527
+ result.delete('content-length')
528
+ result.delete('content-encoding')
529
+
530
+ return result
531
+ }