@atproto/lex-client 0.0.15 → 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,216 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ type Agent,
4
+ type AgentConfig,
5
+ type FetchHandler,
6
+ buildAgent,
7
+ isAgent,
8
+ } from './agent.js'
9
+
10
+ // ============================================================================
11
+ // isAgent
12
+ // ============================================================================
13
+
14
+ describe(isAgent, () => {
15
+ it('returns true for a valid agent with did', () => {
16
+ const agent: Agent = {
17
+ did: 'did:plc:example',
18
+ fetchHandler: async () => new Response(),
19
+ }
20
+ expect(isAgent(agent)).toBe(true)
21
+ })
22
+
23
+ it('returns true for an agent without did', () => {
24
+ const agent = { fetchHandler: async () => new Response() }
25
+ expect(isAgent(agent)).toBe(true)
26
+ })
27
+
28
+ it('returns true when did is undefined', () => {
29
+ const agent = { did: undefined, fetchHandler: async () => new Response() }
30
+ expect(isAgent(agent)).toBe(true)
31
+ })
32
+
33
+ it('returns false for null', () => {
34
+ expect(isAgent(null)).toBe(false)
35
+ })
36
+
37
+ it('returns false for non-objects', () => {
38
+ expect(isAgent('string')).toBe(false)
39
+ expect(isAgent(42)).toBe(false)
40
+ expect(isAgent(undefined)).toBe(false)
41
+ })
42
+
43
+ it('returns false when fetchHandler is not a function', () => {
44
+ expect(isAgent({ fetchHandler: 'not-a-function' })).toBe(false)
45
+ })
46
+
47
+ it('returns false when did is not a string', () => {
48
+ expect(isAgent({ did: 42, fetchHandler: async () => new Response() })).toBe(
49
+ false,
50
+ )
51
+ })
52
+ })
53
+
54
+ // ============================================================================
55
+ // buildAgent
56
+ // ============================================================================
57
+
58
+ describe(buildAgent, () => {
59
+ describe('from Agent', () => {
60
+ it('returns the same agent instance', () => {
61
+ const agent: Agent = {
62
+ did: 'did:plc:example',
63
+ fetchHandler: async () => new Response(),
64
+ }
65
+ expect(buildAgent(agent)).toBe(agent)
66
+ })
67
+ })
68
+
69
+ describe('from FetchHandler', () => {
70
+ it('wraps a function as an agent', () => {
71
+ const handler: FetchHandler = async () => new Response()
72
+ const agent = buildAgent(handler)
73
+
74
+ expect(agent.did).toBeUndefined()
75
+ expect(typeof agent.fetchHandler).toBe('function')
76
+ })
77
+ })
78
+
79
+ describe('from string URL', () => {
80
+ it('creates an agent that prepends the service URL', async () => {
81
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
82
+ Response.json({ ok: true }),
83
+ )
84
+ const agent = buildAgent({
85
+ service: 'https://example.com',
86
+ fetch: fetchFn,
87
+ })
88
+
89
+ await agent.fetchHandler('/xrpc/io.example.test', { method: 'GET' })
90
+
91
+ expect(fetchFn).toHaveBeenCalledOnce()
92
+ const [url, init] = fetchFn.mock.calls[0]
93
+ expect(url).toEqual(
94
+ new URL('/xrpc/io.example.test', 'https://example.com'),
95
+ )
96
+ expect(init?.method).toBe('GET')
97
+ })
98
+
99
+ it('has undefined did', () => {
100
+ const agent = buildAgent('https://example.com')
101
+ expect(agent.did).toBeUndefined()
102
+ })
103
+ })
104
+
105
+ describe('from URL instance', () => {
106
+ it('creates an agent with the URL as service', async () => {
107
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
108
+ Response.json({ ok: true }),
109
+ )
110
+ const agent = buildAgent({
111
+ service: new URL('https://example.com'),
112
+ fetch: fetchFn,
113
+ })
114
+
115
+ await agent.fetchHandler('/xrpc/io.example.test', { method: 'GET' })
116
+
117
+ expect(fetchFn).toHaveBeenCalledOnce()
118
+ const [url] = fetchFn.mock.calls[0]
119
+ expect(url).toEqual(
120
+ new URL('/xrpc/io.example.test', 'https://example.com'),
121
+ )
122
+ })
123
+ })
124
+
125
+ describe('from AgentConfig', () => {
126
+ it('exposes did from config', () => {
127
+ const agent = buildAgent({
128
+ did: 'did:plc:test123',
129
+ service: 'https://example.com',
130
+ })
131
+ expect(agent.did).toBe('did:plc:test123')
132
+ })
133
+
134
+ it('reflects did changes on the config object', () => {
135
+ const config: AgentConfig = {
136
+ did: 'did:plc:original',
137
+ service: 'https://example.com',
138
+ }
139
+ const agent = buildAgent(config)
140
+ expect(agent.did).toBe('did:plc:original')
141
+
142
+ config.did = 'did:plc:updated'
143
+ expect(agent.did).toBe('did:plc:updated')
144
+ })
145
+
146
+ it('throws TypeError when fetch is not available', () => {
147
+ const originalFetch = globalThis.fetch
148
+ try {
149
+ // @ts-expect-error removing fetch to simulate missing environment
150
+ globalThis.fetch = undefined
151
+ expect(() => buildAgent({ service: 'https://example.com' })).toThrow(
152
+ TypeError,
153
+ )
154
+ } finally {
155
+ globalThis.fetch = originalFetch
156
+ }
157
+ })
158
+ })
159
+
160
+ describe('headers', () => {
161
+ it('sends config headers when no request headers', async () => {
162
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
163
+ Response.json({}),
164
+ )
165
+ const agent = buildAgent({
166
+ service: 'https://example.com',
167
+ headers: { Authorization: 'Bearer token123' },
168
+ fetch: fetchFn,
169
+ })
170
+
171
+ await agent.fetchHandler('/xrpc/test', { method: 'GET' })
172
+
173
+ const [, init] = fetchFn.mock.calls[0]
174
+ expect(init?.headers).toEqual({ Authorization: 'Bearer token123' })
175
+ })
176
+
177
+ it('sends request headers when no config headers', async () => {
178
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
179
+ Response.json({}),
180
+ )
181
+ const agent = buildAgent({
182
+ service: 'https://example.com',
183
+ fetch: fetchFn,
184
+ })
185
+
186
+ await agent.fetchHandler('/xrpc/test', {
187
+ method: 'GET',
188
+ headers: { 'X-Custom': 'value' },
189
+ })
190
+
191
+ const [, init] = fetchFn.mock.calls[0]
192
+ expect(init?.headers).toEqual({ 'X-Custom': 'value' })
193
+ })
194
+
195
+ it('merges config and request headers, with request taking priority', async () => {
196
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
197
+ Response.json({}),
198
+ )
199
+ const agent = buildAgent({
200
+ service: 'https://example.com',
201
+ headers: { Authorization: 'Bearer default', 'X-Default': 'yes' },
202
+ fetch: fetchFn,
203
+ })
204
+
205
+ await agent.fetchHandler('/xrpc/test', {
206
+ method: 'GET',
207
+ headers: { Authorization: 'Bearer override' },
208
+ })
209
+
210
+ const [, init] = fetchFn.mock.calls[0]
211
+ const headers = new Headers(init?.headers)
212
+ expect(headers.get('Authorization')).toBe('Bearer override')
213
+ expect(headers.get('X-Default')).toBe('yes')
214
+ })
215
+ })
216
+ })
package/src/agent.ts CHANGED
@@ -101,7 +101,7 @@ export type AgentConfig = {
101
101
  *
102
102
  * Can be a full {@link AgentConfig} object, or a simple service URL string/{@link URL}.
103
103
  */
104
- export type AgentOptions = AgentConfig | string | URL
104
+ export type AgentOptions = AgentConfig | FetchHandler | string | URL
105
105
 
106
106
  /**
107
107
  * Creates an {@link Agent} from various input types.
@@ -132,12 +132,14 @@ export function buildAgent<O extends Agent | AgentOptions>(
132
132
  options: O,
133
133
  ): O extends Agent ? O : Agent
134
134
  export function buildAgent(options: Agent | AgentOptions): Agent {
135
- if (isAgent(options)) return options
136
-
137
135
  const config: Agent | AgentConfig =
138
- typeof options === 'string' || options instanceof URL
139
- ? { did: undefined, service: options }
140
- : options
136
+ typeof options === 'function'
137
+ ? { did: undefined, fetchHandler: options }
138
+ : typeof options === 'string' || options instanceof URL
139
+ ? { did: undefined, service: options }
140
+ : options
141
+
142
+ if (isAgent(config)) return config
141
143
 
142
144
  const { service, fetch = globalThis.fetch } = config
143
145
 
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { IssueInvalidType, LexValidationError, l } from '@atproto/lex-schema'
3
3
  import {
4
4
  XrpcAuthenticationError,
5
+ XrpcFetchError,
5
6
  XrpcInternalError,
6
7
  XrpcInvalidResponseError,
7
8
  XrpcResponseError,
@@ -13,14 +14,14 @@ import {
13
14
  const testQuery = l.query(
14
15
  'io.example.test',
15
16
  l.params(),
16
- l.payload('application/json', l.object({ value: l.string() })),
17
+ l.jsonPayload({ value: l.string() }),
17
18
  ['TestError', 'AnotherError'],
18
19
  )
19
20
 
20
21
  const testQueryNoErrors = l.query(
21
22
  'io.example.noErrors',
22
23
  l.params(),
23
- l.payload('application/json', l.object({ value: l.string() })),
24
+ l.jsonPayload({ value: l.string() }),
24
25
  )
25
26
 
26
27
  // ============================================================================
@@ -42,6 +43,7 @@ describe(XrpcResponseError, () => {
42
43
 
43
44
  it('exposes status from the response', () => {
44
45
  const err = createResponseError(404, 'NotFound')
46
+ expect(err.reason).toBe(err)
45
47
  expect(err.status).toBe(404)
46
48
  })
47
49
 
@@ -54,6 +56,7 @@ describe(XrpcResponseError, () => {
54
56
  encoding: 'application/json',
55
57
  body: { error: 'TestError' },
56
58
  })
59
+ expect(err.reason).toBe(err)
57
60
  expect(err.headers.get('X-Test')).toBe('value')
58
61
  })
59
62
 
@@ -63,7 +66,7 @@ describe(XrpcResponseError, () => {
63
66
  })
64
67
 
65
68
  describe('toDownstreamError', () => {
66
- it('returns 502 for 5xx upstream errors', () => {
69
+ it('returns 502 for upstream 500 errors', () => {
67
70
  const err = createResponseError(
68
71
  500,
69
72
  'InternalServerError',
@@ -78,6 +81,17 @@ describe(XrpcResponseError, () => {
78
81
  })
79
82
  })
80
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
+
81
95
  it('preserves original status for 4xx errors', () => {
82
96
  const err = createResponseError(404, 'NotFound', 'Record not found')
83
97
  const downstream = err.toDownstreamError()
@@ -159,6 +173,7 @@ describe(XrpcAuthenticationError, () => {
159
173
  encoding: 'application/json',
160
174
  body: { error: 'AuthenticationRequired' },
161
175
  })
176
+ expect(err.reason).toBe(err)
162
177
  expect(err.wwwAuthenticate).toHaveProperty('Bearer')
163
178
  })
164
179
 
@@ -195,6 +210,7 @@ describe(XrpcUpstreamError, () => {
195
210
  it('has error code UpstreamFailure', () => {
196
211
  const response = new Response(null, { status: 200 })
197
212
  const err = new XrpcUpstreamError(testQuery, response)
213
+ expect(err.reason).toBe(err)
198
214
  expect(err.error).toBe('UpstreamFailure')
199
215
  })
200
216
 
@@ -236,6 +252,7 @@ describe(XrpcInvalidResponseError, () => {
236
252
  )
237
253
 
238
254
  expect(err).toBeInstanceOf(XrpcUpstreamError)
255
+ expect(err.reason).toBe(err)
239
256
  expect(err.error).toBe('UpstreamFailure')
240
257
  expect(err.cause).toBe(validationError)
241
258
  })
@@ -277,14 +294,10 @@ describe(XrpcInvalidResponseError, () => {
277
294
  describe(XrpcInternalError, () => {
278
295
  it('has error code InternalServerError', () => {
279
296
  const err = new XrpcInternalError(testQuery)
297
+ expect(err.reason).toBe(err)
280
298
  expect(err.error).toBe('InternalServerError')
281
299
  })
282
300
 
283
- it('is always retryable', () => {
284
- const err = new XrpcInternalError(testQuery)
285
- expect(err.shouldRetry()).toBe(true)
286
- })
287
-
288
301
  it('toJSON does not expose internal details', () => {
289
302
  const err = new XrpcInternalError(
290
303
  testQuery,
@@ -305,6 +318,62 @@ describe(XrpcInternalError, () => {
305
318
  expect(downstream.body.error).toBe('InternalServerError')
306
319
  expect(downstream.body.message).toBe('Internal Server Error')
307
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
+ })
308
377
  })
309
378
 
310
379
  // ============================================================================
package/src/errors.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  ResultFailure,
8
8
  lexErrorDataSchema,
9
9
  } from '@atproto/lex-schema'
10
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
11
+ import { Agent } from './agent.js'
10
12
  import { XrpcResponsePayload } from './util.js'
11
13
  import {
12
14
  WWWAuthenticate,
@@ -347,17 +349,11 @@ export class XrpcInvalidResponseError<
347
349
  }
348
350
 
349
351
  /**
350
- * Error class for internal/client-side errors during XRPC requests.
352
+ * Error class for unexpected internal/client-side errors during XRPC requests.
351
353
  *
352
- * This represents errors that occur before or during the request that are not
353
- * server responses, such as:
354
- * - Network errors (connection refused, DNS failure)
355
- * - Request timeouts
356
- * - Request aborted via AbortSignal
357
- * - Invalid request construction
358
- *
359
- * The error code is always 'InternalServerError' and these errors are
360
- * 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.
361
357
  *
362
358
  * @typeParam M - The XRPC method type
363
359
  */
@@ -379,15 +375,11 @@ export class XrpcInternalError<
379
375
  return this
380
376
  }
381
377
 
382
- override shouldRetry(): true {
383
- // Ideally, we would inspect the reason to determine if it's retryable
384
- // (by detecting network errors, timeouts, etc.). Since these cases are
385
- // highly platform-dependent, we optimistically assume all internal
386
- // errors are retryable.
387
- return true
378
+ override shouldRetry(): boolean {
379
+ return false
388
380
  }
389
381
 
390
- override toJSON(): LexErrorData<'InternalServerError'> {
382
+ override toJSON(): LexErrorData {
391
383
  // @NOTE Do not expose internal error details to downstream clients
392
384
  return { error: this.error, message: 'Internal Server Error' }
393
385
  }
@@ -397,6 +389,49 @@ export class XrpcInternalError<
397
389
  }
398
390
  }
399
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.
417
+ return true
418
+ }
419
+
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() }
432
+ }
433
+ }
434
+
400
435
  /**
401
436
  * Union type of all possible XRPC failure types.
402
437
  *