@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.
- package/CHANGELOG.md +24 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +7 -5
- package/dist/agent.js.map +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -4
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +56 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +135 -33
- package/dist/errors.js.map +1 -1
- package/dist/response.d.ts +1 -1
- package/dist/response.d.ts.map +1 -1
- package/dist/response.js +2 -2
- package/dist/response.js.map +1 -1
- package/dist/util.d.ts +1 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +6 -0
- package/dist/util.js.map +1 -1
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +3 -1
- package/dist/xrpc.js.map +1 -1
- package/package.json +6 -6
- package/src/agent.test.ts +216 -0
- package/src/agent.ts +8 -6
- package/src/client.ts +4 -4
- package/src/errors.test.ts +415 -0
- package/src/errors.ts +169 -33
- package/src/response.ts +4 -4
- package/src/util.test.ts +333 -0
- package/src/util.ts +9 -0
- package/src/xrpc.test.ts +904 -0
- package/src/xrpc.ts +4 -2
|
@@ -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
|
-
|
|
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
|
|
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
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
284
|
-
return
|
|
310
|
+
override toDownstreamError(): DownstreamError {
|
|
311
|
+
return { status: 502, body: this.toJSON() }
|
|
285
312
|
}
|
|
286
313
|
}
|
|
287
314
|
|
|
288
315
|
/**
|
|
289
|
-
* Error class for
|
|
316
|
+
* Error class for invalid XRPC responses that fail schema validation.
|
|
290
317
|
*
|
|
291
|
-
* This
|
|
292
|
-
* server
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
|
299
|
-
*
|
|
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():
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
330
|
-
// Do not expose internal error details to downstream clients
|
|
331
|
-
return
|
|
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
|
+
}
|