@atproto/lex-client 0.0.17 → 0.0.19

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,4 +1,9 @@
1
- import { LexError, LexErrorCode, LexErrorData } from '@atproto/lex-data'
1
+ import {
2
+ LexError,
3
+ LexErrorCode,
4
+ LexErrorData,
5
+ LexValue,
6
+ } from '@atproto/lex-data'
2
7
  import {
3
8
  InferMethodError,
4
9
  LexValidationError,
@@ -15,6 +20,28 @@ import {
15
20
  parseWWWAuthenticateHeader,
16
21
  } from './www-authenticate.js'
17
22
 
23
+ /**
24
+ * Mapping that allows generating an XRPC error code from an HTTP status code
25
+ * when the response does not contain a valid XRPC error payload. This is used
26
+ * to convert non-XRPC error responses from upstream servers into a standardized
27
+ * XRPC error for downstream clients.
28
+ */
29
+ const StatusErrorCodes = new Map<number, LexErrorCode>([
30
+ [400, 'InvalidRequest'],
31
+ [401, 'AuthenticationRequired'],
32
+ [403, 'Forbidden'],
33
+ [404, 'XRPCNotSupported'],
34
+ [406, 'NotAcceptable'],
35
+ [413, 'PayloadTooLarge'],
36
+ [415, 'UnsupportedMediaType'],
37
+ [429, 'RateLimitExceeded'],
38
+ [500, 'InternalServerError'],
39
+ [501, 'MethodNotImplemented'],
40
+ [502, 'UpstreamFailure'],
41
+ [503, 'NotEnoughResources'],
42
+ [504, 'UpstreamTimeout'],
43
+ ])
44
+
18
45
  export type { XrpcUnknownResponsePayload }
19
46
 
20
47
  export type DownstreamError<N extends LexErrorCode = LexErrorCode> = {
@@ -90,7 +117,7 @@ export function isXrpcErrorPayload(
90
117
  * @typeParam TReason - The reason type for ResultFailure
91
118
  *
92
119
  * @see {@link XrpcResponseError} - For valid XRPC error responses
93
- * @see {@link XrpcUpstreamError} - For invalid/unexpected responses
120
+ * @see {@link XrpcInvalidResponseError} - For invalid/unexpected responses
94
121
  * @see {@link XrpcInternalError} - For network/internal errors
95
122
  */
96
123
  export abstract class XrpcError<
@@ -160,17 +187,23 @@ export abstract class XrpcError<
160
187
  */
161
188
  export class XrpcResponseError<
162
189
  M extends Procedure | Query = Procedure | Query,
163
- N extends LexErrorCode = InferMethodError<M> | LexErrorCode,
164
- > extends XrpcError<M, N, XrpcResponseError<M, N>> {
190
+ > extends XrpcError<M, LexErrorCode, XrpcResponseError<M>> {
165
191
  name = 'XrpcResponseError'
166
192
 
167
193
  constructor(
168
194
  method: M,
169
195
  readonly response: Response,
170
- readonly payload: XrpcErrorPayload<N>,
196
+ readonly payload?: XrpcUnknownResponsePayload,
171
197
  options?: ErrorOptions,
172
198
  ) {
173
- const { error, message } = payload.body
199
+ const { error, message } = isXrpcErrorPayload(payload)
200
+ ? payload.body
201
+ : {
202
+ error:
203
+ StatusErrorCodes.get(response.status) ??
204
+ (response.status >= 500 ? 'UpstreamFailure' : 'InvalidRequest'),
205
+ message: buildResponseOverviewMessage(response),
206
+ }
174
207
  super(method, error, message, options)
175
208
  }
176
209
 
@@ -182,19 +215,27 @@ export class XrpcResponseError<
182
215
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
183
216
  }
184
217
 
185
- override toJSON(): LexErrorData<N> {
186
- return this.payload.body
218
+ override toJSON(): LexErrorData {
219
+ // Return the original error payload if it's a valid XRPC error, otherwise
220
+ // convert to an XRPC error format.
221
+ const { payload } = this
222
+ if (isXrpcErrorPayload(payload)) {
223
+ return payload.body
224
+ }
225
+
226
+ return super.toJSON()
187
227
  }
188
228
 
189
229
  override toDownstreamError(): DownstreamError {
190
- // If the upstream server returned a 5xx error, we want to return a 502 Bad
230
+ const { status, headers } = this.response
231
+ // If the upstream server returned a 500 error, we want to return a 502 Bad
191
232
  // Gateway to downstream clients, as the issue is with the upstream server,
192
233
  // not us. We still return the original error code and message in the body
193
234
  // for transparency, but we do not want to expose internal server errors
194
235
  // from the upstream server as-is to downstream clients.
195
236
  return {
196
- status: this.response.status === 500 ? 502 : this.status,
197
- headers: stripHopByHopHeaders(this.headers),
237
+ status: status === 500 ? 502 : status,
238
+ headers: stripHopByHopHeaders(headers),
198
239
  body: this.toJSON(),
199
240
  }
200
241
  }
@@ -207,8 +248,8 @@ export class XrpcResponseError<
207
248
  return this.response.headers
208
249
  }
209
250
 
210
- get body(): LexErrorData<N> {
211
- return this.payload.body
251
+ get body(): undefined | Uint8Array | LexValue {
252
+ return this.payload?.body
212
253
  }
213
254
  }
214
255
 
@@ -242,8 +283,7 @@ export type { WWWAuthenticate }
242
283
  */
243
284
  export class XrpcAuthenticationError<
244
285
  M extends Procedure | Query = Procedure | Query,
245
- N extends LexErrorCode = LexErrorCode,
246
- > extends XrpcResponseError<M, N> {
286
+ > extends XrpcResponseError<M> {
247
287
  name = 'XrpcAuthenticationError'
248
288
 
249
289
  override shouldRetry(): boolean {
@@ -261,14 +301,6 @@ export class XrpcAuthenticationError<
261
301
  this.response.headers.get('www-authenticate'),
262
302
  ) ?? {})
263
303
  }
264
-
265
- override toDownstreamError(): DownstreamError {
266
- return {
267
- status: 401,
268
- headers: stripHopByHopHeaders(this.headers),
269
- body: this.toJSON(),
270
- }
271
- }
272
304
  }
273
305
 
274
306
  /**
@@ -281,24 +313,25 @@ export class XrpcAuthenticationError<
281
313
  * - Non-JSON error responses
282
314
  * - Responses from non-XRPC endpoints
283
315
  *
284
- * The error code is always 'UpstreamFailure' and maps to HTTP 502 Bad Gateway
285
- * when converted to a response.
316
+ * The error code is always 'InvalidResponse' and maps to HTTP 502 Bad Gateway
317
+ * when converted to a response. This should allow downstream clients to
318
+ * determine at which boundary the error occurred.
286
319
  *
287
320
  * @typeParam M - The XRPC method type
288
321
  */
289
- export class XrpcUpstreamError<
322
+ export class XrpcInvalidResponseError<
290
323
  M extends Procedure | Query = Procedure | Query,
291
- > extends XrpcError<M, 'UpstreamFailure', XrpcUpstreamError<M>> {
292
- name = 'XrpcUpstreamError'
324
+ > extends XrpcError<M, 'InvalidResponse', XrpcInvalidResponseError<M>> {
325
+ name = 'XrpcInvalidResponseError'
293
326
 
294
327
  constructor(
295
328
  method: M,
296
329
  readonly response: Response,
297
- readonly payload: XrpcUnknownResponsePayload | null = null,
298
- message: string = `Unexpected upstream XRPC response`,
330
+ readonly payload?: XrpcUnknownResponsePayload,
331
+ message: string = buildResponseOverviewMessage(response),
299
332
  options?: ErrorOptions,
300
333
  ) {
301
- super(method, 'UpstreamFailure', message, options)
334
+ super(method, 'InvalidResponse', message, options)
302
335
  }
303
336
 
304
337
  override get reason(): this {
@@ -317,7 +350,7 @@ export class XrpcUpstreamError<
317
350
  /**
318
351
  * Error class for invalid XRPC responses that fail schema validation.
319
352
  *
320
- * This is a specific type of {@link XrpcUpstreamError} that indicates the
353
+ * This is a specific type of {@link XrpcInvalidResponseError} that indicates the
321
354
  * upstream server returned a response that was structurally valid but did not
322
355
  * conform to the expected schema for the method. This likely indicates a
323
356
  * mismatch between client and server versions or an issue with the server's
@@ -325,10 +358,10 @@ export class XrpcUpstreamError<
325
358
  *
326
359
  * @typeParam M - The XRPC method type
327
360
  */
328
- export class XrpcInvalidResponseError<
361
+ export class XrpcResponseValidationError<
329
362
  M extends Procedure | Query = Procedure | Query,
330
- > extends XrpcUpstreamError<M> {
331
- name = 'XrpcInvalidResponseError'
363
+ > extends XrpcInvalidResponseError<M> {
364
+ name = 'XrpcResponseValidationError'
332
365
 
333
366
  constructor(
334
367
  method: M,
@@ -336,17 +369,13 @@ export class XrpcInvalidResponseError<
336
369
  payload: XrpcUnknownResponsePayload,
337
370
  readonly cause: LexValidationError,
338
371
  ) {
339
- super(method, response, payload, `Invalid response: ${cause.message}`, {
340
- cause,
341
- })
342
- }
343
-
344
- override toDownstreamError(): DownstreamError {
345
- // @NOTE This could be reflected as both a 500 ("we" are at fault) and 502
346
- // ("they" are at fault). We are using 502 here to allow downstream clients
347
- // to determine that the issue lies at the interface between us and the
348
- // upstream server, rather than an issue with our internal processing.
349
- return { status: 502, body: this.toJSON() }
372
+ super(
373
+ method,
374
+ response,
375
+ payload,
376
+ `Invalid response payload: ${cause.message}`,
377
+ { cause },
378
+ )
350
379
  }
351
380
  }
352
381
 
@@ -448,7 +477,7 @@ export class XrpcFetchError<
448
477
  * if (result.success) {
449
478
  * console.log(result.body) // XrpcResponse
450
479
  * } else {
451
- * // result is XrpcFailure (XrpcResponseError | XrpcUpstreamError | XrpcInternalError)
480
+ * // result is XrpcFailure (XrpcResponseError | XrpcInvalidResponseError | XrpcInternalError)
452
481
  * console.error(result.error, result.message)
453
482
  * }
454
483
  * ```
@@ -457,7 +486,7 @@ export type XrpcFailure<M extends Procedure | Query = Procedure | Query> =
457
486
  // The server returned a valid XRPC error response
458
487
  | XrpcResponseError<M>
459
488
  // The response was not a valid XRPC response, or it does not match the schema
460
- | XrpcUpstreamError<M>
489
+ | XrpcInvalidResponseError<M>
461
490
  // Something went wrong (network error, etc.)
462
491
  | XrpcInternalError<M>
463
492
 
@@ -487,7 +516,7 @@ export function asXrpcFailure<M extends Procedure | Query>(
487
516
  ): XrpcFailure<M> {
488
517
  if (
489
518
  cause instanceof XrpcResponseError ||
490
- cause instanceof XrpcUpstreamError ||
519
+ cause instanceof XrpcInvalidResponseError ||
491
520
  cause instanceof XrpcInternalError
492
521
  ) {
493
522
  if (cause.method === method) return cause
@@ -531,3 +560,11 @@ function stripHopByHopHeaders(headers: Headers): Headers {
531
560
 
532
561
  return result
533
562
  }
563
+
564
+ function buildResponseOverviewMessage(response: Response): string {
565
+ if (response.status < 400) {
566
+ return `Upstream server responded with an invalid status code (${response.status})`
567
+ }
568
+
569
+ return `Upstream server responded with a ${response.status} error`
570
+ }
@@ -15,9 +15,7 @@ const main =
15
15
  $nsid,
16
16
  /*#__PURE__*/ l.params(),
17
17
  /*#__PURE__*/ l.payload('*/*'),
18
- /*#__PURE__*/ l.jsonPayload({
19
- blob: /*#__PURE__*/ l.blob({ allowLegacy: false }),
20
- }),
18
+ /*#__PURE__*/ l.jsonPayload({ blob: /*#__PURE__*/ l.blob() }),
21
19
  )
22
20
  export { main }
23
21
 
package/src/response.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LexParseOptions, lexParse } from '@atproto/lex-json'
1
+ import { LexParseOptions, lexParseJsonBytes } from '@atproto/lex-json'
2
2
  import {
3
3
  InferMethodOutputEncoding,
4
4
  InferOutput,
@@ -13,8 +13,7 @@ import {
13
13
  XrpcAuthenticationError,
14
14
  XrpcInvalidResponseError,
15
15
  XrpcResponseError,
16
- XrpcUpstreamError,
17
- isXrpcErrorPayload,
16
+ XrpcResponseValidationError,
18
17
  } from './errors.js'
19
18
  import {
20
19
  EncodingString,
@@ -58,7 +57,7 @@ export type XrpcResponseBody<M extends Procedure | Query> =
58
57
  M['output'] extends Payload<infer TEncoding, infer TSchema>
59
58
  ? TEncoding extends string
60
59
  ? InferBodyType<TEncoding, TSchema>
61
- : undefined
60
+ : undefined | LexValue | Uint8Array
62
61
  : never
63
62
 
64
63
  /**
@@ -75,7 +74,9 @@ export type XrpcResponsePayload<M extends Procedure | Query> =
75
74
  encoding: InferEncodingType<TEncoding>
76
75
  body: InferBodyType<TEncoding, TSchema>
77
76
  }
78
- : undefined
77
+ : // If the schema does not specify an output encoding, anything could be
78
+ // returned, including no payload at all (undefined).
79
+ undefined | { body: LexValue | Uint8Array; encoding: string }
79
80
  : never
80
81
 
81
82
  export type XrpcResponseOptions = {
@@ -170,7 +171,7 @@ export class XrpcResponse<M extends Procedure | Query>
170
171
  * {@link XrpcResponseError.matchesSchemaErrors} to narrow the error type based on
171
172
  * the method's declared error schema. This can be narrowed further as a
172
173
  * {@link XrpcAuthenticationError} if the error is an authentication error.
173
- * @throws {XrpcUpstreamError} when the response is not a valid XRPC
174
+ * @throws {XrpcInvalidResponseError} when the response is not a valid XRPC
174
175
  * response, or if the response does not conform to the method's schema.
175
176
  */
176
177
  static async fromFetchResponse<const M extends Procedure | Query>(
@@ -182,63 +183,62 @@ export class XrpcResponse<M extends Procedure | Query>
182
183
  // Since nothing should cause an exception before "readPayload" is
183
184
  // called, we can safely not use a try/finally here.
184
185
 
185
- // @NOTE redirect is set to 'follow', so we shouldn't get 3xx responses here
186
- if (response.status < 200 || response.status >= 300) {
187
- // Always parse json for error responses
186
+ // Always turn 4xx/5xx responses into XrpcResponseError
187
+ if (response.status >= 400) {
188
188
  const payload = await readPayload(method, response, {
189
- parse: { strict: options?.strictResponseProcessing ?? true },
189
+ // Always parse errors in non-strict mode
190
+ parse: { strict: false },
190
191
  })
191
192
 
192
- // Properly formatted XRPC error response ?
193
- if (response.status >= 400 && isXrpcErrorPayload(payload)) {
194
- throw response.status === 401
195
- ? new XrpcAuthenticationError<M>(method, response, payload)
196
- : new XrpcResponseError<M>(method, response, payload)
193
+ if (response.status === 401) {
194
+ throw new XrpcAuthenticationError<M>(method, response, payload)
197
195
  }
198
196
 
199
- // Invalid XRPC response (we probably did not hit an XRPC implementation)
200
- throw new XrpcUpstreamError(
197
+ throw new XrpcResponseError<M>(method, response, payload)
198
+ }
199
+
200
+ // @NOTE redirect is set to 'follow', so we shouldn't get 3xx responses here
201
+ if (response.status < 200 || response.status >= 300) {
202
+ await response.body?.cancel()
203
+
204
+ throw new XrpcInvalidResponseError(
201
205
  method,
202
206
  response,
203
- payload,
204
- response.status >= 500
205
- ? 'Upstream server encountered an error'
206
- : response.status >= 400
207
- ? 'Invalid response payload'
208
- : 'Invalid response status code',
207
+ undefined,
208
+ `Unexpected status code ${response.status}`,
209
209
  )
210
210
  }
211
211
 
212
212
  const payload = await readPayload(method, response, {
213
- // Only parse json if the schema expects it
214
- parse: method.output.encoding === CONTENT_TYPE_JSON && {
215
- strict: options?.strictResponseProcessing ?? true,
216
- },
213
+ // Parse response if there is a schema, or if the encoding is
214
+ // "application/json"
215
+ parse:
216
+ method.output.schema || method.output.encoding === CONTENT_TYPE_JSON
217
+ ? { strict: options?.strictResponseProcessing ?? true }
218
+ : // If there is no declared output encoding, we'll parse the output (in loose mode)
219
+ method.output.encoding == null
220
+ ? { strict: false }
221
+ : false,
217
222
  })
218
223
 
224
+ if (!method.output.matchesEncoding(payload?.encoding)) {
225
+ throw new XrpcInvalidResponseError(
226
+ method,
227
+ response,
228
+ payload,
229
+ `Expected ${stringifyEncoding(method.output.encoding)} response (got ${stringifyEncoding(payload?.encoding)})`,
230
+ )
231
+ }
232
+
219
233
  // Response is successful (2xx). Validate payload (data and encoding) against schema.
220
- if (method.output.encoding == null) {
221
- // Schema expects no payload
222
- if (payload) {
223
- throw new XrpcUpstreamError(
224
- method,
225
- response,
226
- payload,
227
- `Expected response with no body, got ${payload.encoding}`,
228
- )
229
- }
230
- } else {
231
- // Schema expects a payload
232
- if (!payload || !method.output.matchesEncoding(payload.encoding)) {
233
- throw new XrpcUpstreamError(
234
- method,
235
- response,
236
- payload,
237
- payload
238
- ? `Expected ${method.output.encoding} response, got ${payload.encoding}`
239
- : `Expected non-empty response with content-type ${method.output.encoding}`,
240
- )
241
- }
234
+ if (method.output.encoding != null) {
235
+ // If the schema specifies an output, verify that the response properly
236
+ // matches the expected format (encoding and schema, if present). If no
237
+ // output is specified, any payload could be returned.
238
+
239
+ // Needed for type safety. Should never happen since matchesEncoding()
240
+ // should return not succeed if there is a schema encoding but no payload.
241
+ if (!payload) throw new Error('Expected payload')
242
242
 
243
243
  // Assert valid response body.
244
244
  if (method.output.schema && options?.validateResponse !== false) {
@@ -247,7 +247,7 @@ export class XrpcResponse<M extends Procedure | Query>
247
247
  })
248
248
 
249
249
  if (!result.success) {
250
- throw new XrpcInvalidResponseError(
250
+ throw new XrpcResponseValidationError(
251
251
  method,
252
252
  response,
253
253
  payload,
@@ -323,19 +323,10 @@ async function readPayload(
323
323
  }
324
324
 
325
325
  if (options?.parse && encoding === CONTENT_TYPE_JSON) {
326
- // @NOTE It might be worth returning the raw bytes here (Uint8Array) and
327
- // perform the lex parsing using cborg/json, allowing to do
328
- // bytes->LexValue in one step instead of bytes->text->JSON->LexValue.
329
- // This would require adding encode/decode utilities to lex-json (similar
330
- // to @ipld/dag-json)
331
- const text = await response.text()
332
-
333
- // @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
334
- // using a reviver function during JSON.parse should be faster than
335
- // parsing to JSON then converting to Lex (?)
336
-
337
- // @TODO verify statement above
338
- return { encoding, body: lexParse(text, options.parse) }
326
+ const arrayBuffer = await response.arrayBuffer()
327
+ const bytes = new Uint8Array(arrayBuffer)
328
+ const body = lexParseJsonBytes(bytes, options.parse)
329
+ return { encoding, body }
339
330
  }
340
331
 
341
332
  const arrayBuffer = await response.arrayBuffer()
@@ -343,12 +334,16 @@ async function readPayload(
343
334
  } catch (cause) {
344
335
  const message = 'Unable to parse response payload'
345
336
  const messageDetail = cause instanceof TypeError ? cause.message : undefined
346
- throw new XrpcUpstreamError(
337
+ throw new XrpcInvalidResponseError(
347
338
  method,
348
339
  response,
349
- null,
340
+ undefined,
350
341
  messageDetail ? `${message}: ${messageDetail}` : message,
351
342
  { cause },
352
343
  )
353
344
  }
354
345
  }
346
+
347
+ function stringifyEncoding(encoding: string | undefined) {
348
+ return encoding ? `"${encoding}"` : 'no payload'
349
+ }