@atproto/lex-client 0.0.14 → 0.0.15

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.
package/src/errors.ts CHANGED
@@ -1,6 +1,7 @@
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,
@@ -12,6 +13,13 @@ import {
12
13
  parseWWWAuthenticateHeader,
13
14
  } from './www-authenticate.js'
14
15
 
16
+ export type DownstreamError<N extends LexErrorCode = LexErrorCode> = {
17
+ status: number
18
+ headers?: Headers
19
+ encoding?: 'application/json'
20
+ body: LexErrorData<N>
21
+ }
22
+
15
23
  /**
16
24
  * HTTP status codes that indicate a transient error that may succeed on retry.
17
25
  *
@@ -115,7 +123,9 @@ export abstract class XrpcError<
115
123
  */
116
124
  abstract shouldRetry(): boolean
117
125
 
118
- matchesSchema(): this is XrpcError<M, InferMethodError<M>> {
126
+ abstract toDownstreamError(): DownstreamError
127
+
128
+ matchesSchemaErrors(): this is XrpcError<M, InferMethodError<M>> {
119
129
  return this.method.errors?.includes(this.error) ?? false
120
130
  }
121
131
  }
@@ -127,7 +137,7 @@ export abstract class XrpcError<
127
137
  * a non-2xx status with a valid JSON error payload containing `error` and
128
138
  * optional `message` fields.
129
139
  *
130
- * Use {@link matchesSchema} to check if the error matches the method's declared
140
+ * Use {@link matchesSchemaErrors} to check if the error matches the method's declared
131
141
  * error types for type-safe error handling.
132
142
  *
133
143
  * @typeParam M - The XRPC method type
@@ -168,25 +178,32 @@ export class XrpcResponseError<
168
178
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
169
179
  }
170
180
 
171
- override toJSON() {
181
+ override toJSON(): LexErrorData<N> {
172
182
  return this.payload.body
173
183
  }
174
184
 
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 })
185
+ override toDownstreamError(): DownstreamError {
186
+ // If the upstream server returned a 5xx error, we want to return a 502 Bad
187
+ // Gateway to downstream clients, as the issue is with the upstream server,
188
+ // not us. We still return the original error code and message in the body
189
+ // for transparency, but we do not want to expose internal server errors
190
+ // from the upstream server as-is to downstream clients.
191
+ return {
192
+ status: this.response.status === 500 ? 502 : this.status,
193
+ headers: stripHopByHopHeaders(this.headers),
194
+ body: this.toJSON(),
180
195
  }
196
+ }
181
197
 
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 })
198
+ get status(): number {
199
+ return this.response.status
187
200
  }
188
201
 
189
- get body(): LexErrorData {
202
+ get headers(): Headers {
203
+ return this.response.headers
204
+ }
205
+
206
+ get body(): LexErrorData<N> {
190
207
  return this.payload.body
191
208
  }
192
209
  }
@@ -240,6 +257,14 @@ export class XrpcAuthenticationError<
240
257
  this.response.headers.get('www-authenticate'),
241
258
  ) ?? {})
242
259
  }
260
+
261
+ override toDownstreamError(): DownstreamError {
262
+ return {
263
+ status: 401,
264
+ headers: stripHopByHopHeaders(this.headers),
265
+ body: this.toJSON(),
266
+ }
267
+ }
243
268
  }
244
269
 
245
270
  /**
@@ -280,8 +305,44 @@ export class XrpcUpstreamError<
280
305
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
281
306
  }
282
307
 
283
- override toResponse(): Response {
284
- return Response.json(this.toJSON(), { status: 502 })
308
+ override toDownstreamError(): DownstreamError {
309
+ return { status: 502, body: this.toJSON() }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Error class for invalid XRPC responses that fail schema validation.
315
+ *
316
+ * This is a specific type of {@link XrpcUpstreamError} that indicates the
317
+ * upstream server returned a response that was structurally valid but did not
318
+ * conform to the expected schema for the method. This likely indicates a
319
+ * mismatch between client and server versions or an issue with the server's
320
+ * XRPC implementation.
321
+ *
322
+ * @typeParam M - The XRPC method type
323
+ */
324
+ export class XrpcInvalidResponseError<
325
+ M extends Procedure | Query = Procedure | Query,
326
+ > extends XrpcUpstreamError<M> {
327
+ name = 'XrpcInvalidResponseError'
328
+
329
+ constructor(
330
+ method: M,
331
+ response: Response,
332
+ payload: XrpcResponsePayload,
333
+ readonly cause: LexValidationError,
334
+ ) {
335
+ super(method, response, payload, `Invalid response: ${cause.message}`, {
336
+ cause,
337
+ })
338
+ }
339
+
340
+ override toDownstreamError(): DownstreamError {
341
+ // @NOTE This could be reflected as both a 500 ("we" are at fault) and 502
342
+ // ("they" are at fault). We are using 502 here to allow downstream clients
343
+ // to determine that the issue lies at the interface between us and the
344
+ // upstream server, rather than an issue with our internal processing.
345
+ return { status: 502, body: this.toJSON() }
285
346
  }
286
347
  }
287
348
 
@@ -326,9 +387,13 @@ export class XrpcInternalError<
326
387
  return true
327
388
  }
328
389
 
329
- override toResponse(): Response {
330
- // Do not expose internal error details to downstream clients
331
- return Response.json({ error: this.error }, { status: 500 })
390
+ override toJSON(): LexErrorData<'InternalServerError'> {
391
+ // @NOTE Do not expose internal error details to downstream clients
392
+ return { error: this.error, message: 'Internal Server Error' }
393
+ }
394
+
395
+ override toDownstreamError(): DownstreamError {
396
+ return { status: 500, body: this.toJSON() }
332
397
  }
333
398
  }
334
399
 
@@ -393,3 +458,39 @@ export function asXrpcFailure<M extends Procedure | Query>(
393
458
 
394
459
  return new XrpcInternalError(method, undefined, { cause })
395
460
  }
461
+
462
+ const HOP_BY_HOP_HEADERS = new Set([
463
+ 'connection',
464
+ 'keep-alive',
465
+ 'proxy-authenticate',
466
+ 'proxy-authorization',
467
+ 'te',
468
+ 'trailer',
469
+ 'transfer-encoding',
470
+ 'upgrade',
471
+ ])
472
+
473
+ function stripHopByHopHeaders(headers: Headers): Headers {
474
+ const result = new Headers(headers)
475
+
476
+ // Remove statically known hop-by-hop headers
477
+ for (const name of HOP_BY_HOP_HEADERS) {
478
+ result.delete(name)
479
+ }
480
+
481
+ // Remove headers listed in the "Connection" header
482
+ const connection = headers.get('connection')
483
+ if (connection) {
484
+ for (const name of connection.split(',')) {
485
+ result.delete(name.trim())
486
+ }
487
+ }
488
+
489
+ // These are not actually hop-by-hop headers, but we remove them because the
490
+ // upstream payload gets parsed and re-serialized, so content length and
491
+ // encoding may no longer be accurate.
492
+ result.delete('content-length')
493
+ result.delete('content-encoding')
494
+
495
+ return result
496
+ }
package/src/response.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from '@atproto/lex-schema'
8
8
  import {
9
9
  XrpcAuthenticationError,
10
+ XrpcInvalidResponseError,
10
11
  XrpcResponseError,
11
12
  XrpcUpstreamError,
12
13
  isXrpcErrorPayload,
@@ -70,7 +71,7 @@ export class XrpcResponse<M extends Procedure | Query>
70
71
 
71
72
  /**
72
73
  * @throws {XrpcResponseError} in case of (valid) XRPC error responses. Use
73
- * {@link XrpcResponseError.matchesSchema} to narrow the error type based on
74
+ * {@link XrpcResponseError.matchesSchemaErrors} to narrow the error type based on
74
75
  * the method's declared error schema. This can be narrowed further as a
75
76
  * {@link XrpcAuthenticationError} if the error is an authentication error.
76
77
  * @throws {XrpcUpstreamError} when the response is not a valid XRPC
@@ -162,12 +163,11 @@ export class XrpcResponse<M extends Procedure | Query>
162
163
  const result = method.output.schema.safeParse(payload.body)
163
164
 
164
165
  if (!result.success) {
165
- throw new XrpcUpstreamError(
166
+ throw new XrpcInvalidResponseError(
166
167
  method,
167
168
  response,
168
169
  payload,
169
- `Response validation failed: ${result.reason.message}`,
170
- { cause: result.reason },
170
+ result.reason,
171
171
  )
172
172
  }
173
173
  }