@expandai/sdk 0.19.0 → 0.20.0
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/README.md +130 -4
- package/build/dts/AppendixMarkers.d.ts +30 -0
- package/build/dts/AppendixMarkers.d.ts.map +1 -0
- package/build/dts/Client.d.ts +41 -6
- package/build/dts/Client.d.ts.map +1 -1
- package/build/dts/Crawls.d.ts +568 -0
- package/build/dts/Crawls.d.ts.map +1 -0
- package/build/dts/EnvProxyHttpClient.d.ts +28 -0
- package/build/dts/EnvProxyHttpClient.d.ts.map +1 -0
- package/build/dts/FetchJson.d.ts +444 -37
- package/build/dts/FetchJson.d.ts.map +1 -1
- package/build/dts/Generated.d.ts +1545 -788
- package/build/dts/Generated.d.ts.map +1 -1
- package/build/dts/HttpClientHelpers.d.ts +13 -0
- package/build/dts/HttpClientHelpers.d.ts.map +1 -1
- package/build/dts/Internal.d.ts +6 -4
- package/build/dts/Internal.d.ts.map +1 -1
- package/build/dts/ParseErrorBody.d.ts +26 -0
- package/build/dts/ParseErrorBody.d.ts.map +1 -0
- package/build/dts/Playground.d.ts +20 -0
- package/build/dts/Playground.d.ts.map +1 -1
- package/build/dts/RetryAfter.d.ts +40 -0
- package/build/dts/RetryAfter.d.ts.map +1 -0
- package/build/dts/Service.d.ts +94 -45
- package/build/dts/Service.d.ts.map +1 -1
- package/build/dts/StreamPage.d.ts +10 -0
- package/build/dts/StreamPage.d.ts.map +1 -0
- package/build/dts/index.d.ts +9 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/esm/AppendixMarkers.js +307 -0
- package/build/esm/AppendixMarkers.js.map +1 -0
- package/build/esm/Client.js +96 -15
- package/build/esm/Client.js.map +1 -1
- package/build/esm/Crawls.js +786 -0
- package/build/esm/Crawls.js.map +1 -0
- package/build/esm/EnvProxyHttpClient.js +55 -0
- package/build/esm/EnvProxyHttpClient.js.map +1 -0
- package/build/esm/FetchJson.js +48 -22
- package/build/esm/FetchJson.js.map +1 -1
- package/build/esm/Generated.js +540 -112
- package/build/esm/Generated.js.map +1 -1
- package/build/esm/HttpClientHelpers.js +36 -1
- package/build/esm/HttpClientHelpers.js.map +1 -1
- package/build/esm/Internal.js +140 -33
- package/build/esm/Internal.js.map +1 -1
- package/build/esm/ParseErrorBody.js +41 -0
- package/build/esm/ParseErrorBody.js.map +1 -0
- package/build/esm/Playground.js +38 -0
- package/build/esm/Playground.js.map +1 -1
- package/build/esm/RetryAfter.js +104 -0
- package/build/esm/RetryAfter.js.map +1 -0
- package/build/esm/Service.js +96 -17
- package/build/esm/Service.js.map +1 -1
- package/build/esm/StreamPage.js +46 -0
- package/build/esm/StreamPage.js.map +1 -0
- package/build/esm/index.js +5 -1
- package/build/esm/index.js.map +1 -1
- package/package.json +8 -2
- package/src/AppendixMarkers.ts +370 -0
- package/src/Client.ts +131 -16
- package/src/Crawls.ts +1387 -0
- package/src/EnvProxyHttpClient.ts +71 -0
- package/src/FetchJson.ts +63 -25
- package/src/Generated.ts +738 -162
- package/src/HttpClientHelpers.ts +48 -1
- package/src/Internal.ts +163 -105
- package/src/ParseErrorBody.ts +42 -0
- package/src/Playground.ts +48 -0
- package/src/RetryAfter.ts +138 -0
- package/src/Service.ts +208 -25
- package/src/StreamPage.ts +47 -0
- package/src/index.ts +29 -3
package/src/HttpClientHelpers.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { HttpClientError, HttpClientRequest, HttpClientResponse } from '@effect/platform'
|
|
2
2
|
import { Effect, Schema } from 'effect'
|
|
3
|
+
import { ExpandSdkError } from './Error.js'
|
|
4
|
+
|
|
5
|
+
// Preserve transport status across Effect's tracing proxies without changing
|
|
6
|
+
// the enumerable error body. A wire tag can occur at more than one status.
|
|
7
|
+
const DECODED_ERROR_STATUS = Symbol('DecodedErrorStatus')
|
|
8
|
+
|
|
9
|
+
export function decodedErrorStatus(error: unknown): number | undefined {
|
|
10
|
+
return typeof error === 'object' &&
|
|
11
|
+
error !== null &&
|
|
12
|
+
DECODED_ERROR_STATUS in error &&
|
|
13
|
+
typeof error[DECODED_ERROR_STATUS] === 'number'
|
|
14
|
+
? error[DECODED_ERROR_STATUS]
|
|
15
|
+
: undefined
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
export function unexpectedStatus(
|
|
5
19
|
request: HttpClientRequest.HttpClientRequest,
|
|
@@ -20,5 +34,38 @@ export function unexpectedStatus(
|
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
export function decodeError<A, I, R>(response: HttpClientResponse.HttpClientResponse, schema: Schema.Schema<A, I, R>) {
|
|
23
|
-
|
|
37
|
+
const decoded = HttpClientResponse.schemaBodyJson(schema)(response)
|
|
38
|
+
// Terminal statuses stay terminal even when a gateway sends an unfamiliar
|
|
39
|
+
// JSON body. Preserve the old unexpected-status path instead of retrying a ParseError.
|
|
40
|
+
const body =
|
|
41
|
+
response.status === 404 || response.status === 410
|
|
42
|
+
? decoded.pipe(Effect.catchTag('ParseError', () => unexpectedStatus(response.request, response)))
|
|
43
|
+
: decoded
|
|
44
|
+
return Effect.flatMap(body, (error) => {
|
|
45
|
+
if (typeof error === 'object' && error !== null)
|
|
46
|
+
Object.defineProperty(error, DECODED_ERROR_STATUS, { value: response.status })
|
|
47
|
+
return Effect.fail(error)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decode a *success* body, turning a decode failure into a non-retryable `ExpandSdkError`.
|
|
53
|
+
*
|
|
54
|
+
* The distinction from a bare `ParseError` matters to the retry policy: an error-path decode
|
|
55
|
+
* failure may be an old server's body missing a new field, so `isRetryable` treats raw
|
|
56
|
+
* `ParseError`s as retryable and recovers the verdict from the rejected input. A 2xx body that
|
|
57
|
+
* fails its schema is the opposite case — the server said the capture *succeeded* and this SDK
|
|
58
|
+
* cannot read the answer, which is deterministic client/server skew — and retrying it re-runs a
|
|
59
|
+
* full capture per attempt for the same undecodable result.
|
|
60
|
+
*/
|
|
61
|
+
export function decodeSuccess<A, I, R>(
|
|
62
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
63
|
+
schema: Schema.Schema<A, I, R>,
|
|
64
|
+
) {
|
|
65
|
+
return HttpClientResponse.schemaBodyJson(schema)(response).pipe(
|
|
66
|
+
Effect.catchTag(
|
|
67
|
+
'ParseError',
|
|
68
|
+
(error) => new ExpandSdkError({ message: 'Failed to decode the API response', cause: error }),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
24
71
|
}
|
package/src/Internal.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { HttpClientError } from '@effect/platform'
|
|
2
|
-
import { Cause, Effect, HashSet, Layer, Match, Option, ParseResult } from 'effect'
|
|
2
|
+
import { Cause, Effect, HashSet, Layer, Match, Option, ParseResult, Predicate } from 'effect'
|
|
3
3
|
import {
|
|
4
4
|
ExpandApiError,
|
|
5
|
+
ExpandClientApiError,
|
|
6
|
+
ExpandClientConnectionError,
|
|
5
7
|
ExpandClientError,
|
|
8
|
+
ExpandClientTimeoutError,
|
|
6
9
|
ExpandConnectionError,
|
|
7
10
|
type ExpandError,
|
|
8
11
|
ExpandSdkError,
|
|
9
12
|
ExpandTimeoutError,
|
|
10
13
|
} from './Error.js'
|
|
11
|
-
import
|
|
14
|
+
import { decodedErrorStatus } from './HttpClientHelpers.js'
|
|
15
|
+
import { errorField, parseErrorBody } from './ParseErrorBody.js'
|
|
12
16
|
import {
|
|
13
17
|
type BatchedParams,
|
|
14
18
|
type BatchedRequestOptions,
|
|
@@ -20,21 +24,69 @@ import {
|
|
|
20
24
|
type FetchJsonResponse,
|
|
21
25
|
type FetchParams,
|
|
22
26
|
type FetchResponse,
|
|
23
|
-
type FetchSearchParams,
|
|
24
|
-
type FetchSearchResponse,
|
|
25
27
|
type GetBatchedParams,
|
|
26
28
|
type GetBatchedResponse,
|
|
27
29
|
type RequestOptions,
|
|
30
|
+
type SnapshotCatalog,
|
|
31
|
+
type SnapshotGetParams,
|
|
32
|
+
type SnapshotHighlightsResponse,
|
|
28
33
|
} from './Service.js'
|
|
29
34
|
|
|
30
35
|
const RETRYABLE_STATUS_CODES = HashSet.make(408, 409, 429, 500, 502, 503, 504, 529)
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
// Status for each tagged error the API can return. Decoded error bodies always
|
|
38
|
+
// carry their `_tag`, so this table classifies them without an arm apiece.
|
|
39
|
+
const STATUS_BY_ERROR_TAG = new Map<string, number>([
|
|
40
|
+
['HttpApiDecodeError', 400],
|
|
41
|
+
['BadRequest', 400],
|
|
42
|
+
['BatchedIncludeUnsupported', 400],
|
|
43
|
+
['AuthFailed', 401],
|
|
44
|
+
['AccessBlocked', 403],
|
|
45
|
+
['FetchBlocked', 403],
|
|
46
|
+
['FetchSnapshotHighlightsNotFound', 404],
|
|
47
|
+
['SnapshotNotFound', 404],
|
|
48
|
+
['BatchedIdempotencyConflict', 409],
|
|
49
|
+
['BatchedCancellationConflict', 409],
|
|
50
|
+
['PayloadTooLarge', 413],
|
|
51
|
+
['FetchPageTooLarge', 413],
|
|
52
|
+
['FetchUnsupportedContentType', 415],
|
|
53
|
+
['FetchVideoUnavailable', 422],
|
|
54
|
+
['TooManyRequests', 429],
|
|
55
|
+
['FetchConcurrencyLimitExceeded', 429],
|
|
56
|
+
['InternalError', 500],
|
|
57
|
+
['FetchNavigationFailed', 502],
|
|
58
|
+
['FetchCaptureFailed', 502],
|
|
59
|
+
['FetchOriginHttpError', 502],
|
|
60
|
+
['ServiceUnavailable', 503],
|
|
61
|
+
['FetchDocumentProcessingUnavailable', 503],
|
|
62
|
+
['FetchCaptureTimeout', 504],
|
|
63
|
+
['FetchCapacityTimeout', 529],
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
function statusForErrorTag(error: unknown): Option.Option<number> {
|
|
67
|
+
if (typeof error !== 'object' || error === null || !('_tag' in error) || typeof error._tag !== 'string') {
|
|
68
|
+
return Option.none()
|
|
69
|
+
}
|
|
70
|
+
const transportStatus = decodedErrorStatus(error)
|
|
71
|
+
if (transportStatus !== undefined) return Option.some(transportStatus)
|
|
72
|
+
if (
|
|
73
|
+
error._tag === 'FetchOriginHttpError' &&
|
|
74
|
+
'statusCode' in error &&
|
|
75
|
+
(error.statusCode === 404 || error.statusCode === 410)
|
|
76
|
+
) {
|
|
77
|
+
return Option.some(error.statusCode)
|
|
78
|
+
}
|
|
79
|
+
return Option.fromNullable(STATUS_BY_ERROR_TAG.get(error._tag))
|
|
34
80
|
}
|
|
35
81
|
|
|
36
|
-
|
|
37
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The `_tag` of the body a decode failed on, so a terminal error is not retried merely because this
|
|
84
|
+
* client could not decode the envelope it arrived in. See {@link parseErrorBody}.
|
|
85
|
+
*/
|
|
86
|
+
function parseErrorBodyTag(error: unknown): Option.Option<string> {
|
|
87
|
+
return Option.flatMap(parseErrorBody(error), (body) =>
|
|
88
|
+
Predicate.hasProperty(body, '_tag') && typeof body._tag === 'string' ? Option.some(body._tag) : Option.none(),
|
|
89
|
+
)
|
|
38
90
|
}
|
|
39
91
|
|
|
40
92
|
function apiError(status: number, error: unknown): ExpandApiError {
|
|
@@ -57,9 +109,22 @@ function apiError(status: number, error: unknown): ExpandApiError {
|
|
|
57
109
|
* its public error channel.
|
|
58
110
|
*/
|
|
59
111
|
export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
112
|
+
// Already-typed failures (including the promise-side `ExpandClient*` classes that the Crawl
|
|
113
|
+
// helpers throw) pass through so wrapping never turns a 429 into a bare SDK error.
|
|
114
|
+
if (
|
|
115
|
+
error instanceof ExpandApiError ||
|
|
116
|
+
error instanceof ExpandConnectionError ||
|
|
117
|
+
error instanceof ExpandTimeoutError ||
|
|
118
|
+
error instanceof ExpandSdkError
|
|
119
|
+
)
|
|
120
|
+
return error
|
|
121
|
+
if (error instanceof ExpandClientApiError)
|
|
122
|
+
return new ExpandApiError({ message: error.message, status: error.status, body: error.body, cause: error })
|
|
123
|
+
if (error instanceof ExpandClientTimeoutError)
|
|
124
|
+
return new ExpandTimeoutError({ message: error.message, timeoutMs: error.timeoutMs, cause: error })
|
|
125
|
+
if (error instanceof ExpandClientConnectionError)
|
|
126
|
+
return new ExpandConnectionError({ message: error.message, cause: error })
|
|
127
|
+
return Match.value(error).pipe(
|
|
63
128
|
Match.when(
|
|
64
129
|
(error: unknown) => error instanceof Cause.TimeoutException,
|
|
65
130
|
(error) => new ExpandTimeoutError({ message: `Request timed out after ${timeoutMs}ms`, timeoutMs, cause: error }),
|
|
@@ -79,84 +144,20 @@ export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
|
79
144
|
}),
|
|
80
145
|
),
|
|
81
146
|
Match.when(
|
|
82
|
-
(error: unknown) => error
|
|
83
|
-
(error) =>
|
|
84
|
-
),
|
|
85
|
-
Match.when(
|
|
86
|
-
(error: unknown) => hasApiErrorTag(error, 'AuthFailed'),
|
|
87
|
-
(error) => apiError(401, error),
|
|
88
|
-
),
|
|
89
|
-
Match.when(
|
|
90
|
-
(error: unknown) => hasApiErrorTag(error, 'AccessBlocked'),
|
|
91
|
-
(error) => apiError(403, error),
|
|
92
|
-
),
|
|
93
|
-
Match.when(
|
|
94
|
-
(error: unknown) =>
|
|
95
|
-
hasApiErrorTag(error, 'BatchedIdempotencyConflict') || hasApiErrorTag(error, 'BatchedCancellationConflict'),
|
|
96
|
-
(error) => apiError(409, error),
|
|
97
|
-
),
|
|
98
|
-
Match.when(
|
|
99
|
-
(error: unknown) => error instanceof Generated.PayloadTooLarge,
|
|
100
|
-
(error) => new ExpandApiError({ message: 'PayloadTooLarge', status: 413, body: error, cause: error }),
|
|
101
|
-
),
|
|
102
|
-
Match.when(
|
|
103
|
-
(error: unknown) => hasApiErrorTag(error, 'PayloadTooLarge'),
|
|
104
|
-
(error) => apiError(413, error),
|
|
105
|
-
),
|
|
106
|
-
Match.when(
|
|
107
|
-
(error: unknown) => error instanceof Generated.TooManyRequests,
|
|
108
|
-
(error) => new ExpandApiError({ message: 'TooManyRequests', status: 429, body: error, cause: error }),
|
|
109
|
-
),
|
|
110
|
-
Match.when(
|
|
111
|
-
(error: unknown) => hasApiErrorTag(error, 'TooManyRequests'),
|
|
112
|
-
(error) => apiError(429, error),
|
|
113
|
-
),
|
|
114
|
-
)
|
|
115
|
-
return transportAndClientErrors.pipe(
|
|
116
|
-
Match.when(
|
|
117
|
-
(error: unknown) => error instanceof Generated.InternalError,
|
|
118
|
-
(error) => new ExpandApiError({ message: 'InternalError', status: 500, body: error, cause: error }),
|
|
119
|
-
),
|
|
120
|
-
Match.when(
|
|
121
|
-
(error: unknown) => hasApiErrorTag(error, 'InternalError'),
|
|
122
|
-
(error) => apiError(500, error),
|
|
123
|
-
),
|
|
124
|
-
Match.when(
|
|
125
|
-
(error: unknown) => hasApiErrorTag(error, 'FetchNavigationFailed'),
|
|
126
|
-
(error) => apiError(502, error),
|
|
127
|
-
),
|
|
128
|
-
// No `instanceof Generated.ServiceUnavailable` arm: decoded instances carry `_tag`, so the
|
|
129
|
-
// tag predicate already matches them and both would produce the same `apiError(503, ...)`.
|
|
130
|
-
Match.when(
|
|
131
|
-
(error: unknown) => hasApiErrorTag(error, 'ServiceUnavailable'),
|
|
132
|
-
(error) => apiError(503, error),
|
|
133
|
-
),
|
|
134
|
-
Match.when(
|
|
135
|
-
(error: unknown) => hasApiErrorTag(error, 'FetchCaptureTimeout'),
|
|
136
|
-
(error) => apiError(504, error),
|
|
137
|
-
),
|
|
138
|
-
Match.when(
|
|
139
|
-
(error: unknown) => hasApiErrorTag(error, 'FetchCapacityTimeout'),
|
|
140
|
-
(error) => apiError(529, error),
|
|
141
|
-
),
|
|
142
|
-
Match.when(
|
|
143
|
-
(error: unknown) => error instanceof Generated.HttpApiDecodeError,
|
|
144
|
-
(error) => new ExpandApiError({ message: 'HttpApiDecodeError', status: 400, body: error, cause: error }),
|
|
145
|
-
),
|
|
146
|
-
Match.when(
|
|
147
|
-
(error: unknown) => hasApiErrorTag(error, 'HttpApiDecodeError') || hasApiErrorTag(error, 'BadRequest'),
|
|
148
|
-
(error) => apiError(400, error),
|
|
149
|
-
),
|
|
150
|
-
Match.when(
|
|
151
|
-
(error: unknown) => hasApiErrorTag(error, 'FetchSnapshotSearchNotFound'),
|
|
152
|
-
(error) => apiError(404, error),
|
|
147
|
+
(error: unknown) => Option.isSome(statusForErrorTag(error)),
|
|
148
|
+
(error) => apiError(Option.getOrThrow(statusForErrorTag(error)), error),
|
|
153
149
|
),
|
|
154
150
|
// A response we couldn't decode (e.g. a 5xx whose body is empty or doesn't match the declared
|
|
155
151
|
// error schema) arrives as a bare ParseError with no status. Surface a clear SDK error instead
|
|
156
|
-
// of dumping the raw schema-mismatch tree as the message
|
|
152
|
+
// of dumping the raw schema-mismatch tree as the message — unless the body named an error class
|
|
153
|
+
// we recognize, in which case the caller gets its status rather than paying for our skew.
|
|
157
154
|
Match.when(
|
|
158
155
|
(error: unknown) => ParseResult.isParseError(error),
|
|
159
|
-
(error) =>
|
|
156
|
+
(error) =>
|
|
157
|
+
Option.getOrElse(
|
|
158
|
+
recoverUndecodedApiError(error, timeoutMs),
|
|
159
|
+
() => new ExpandSdkError({ message: 'Failed to decode the API response', cause: error }),
|
|
160
|
+
),
|
|
160
161
|
),
|
|
161
162
|
Match.orElse(
|
|
162
163
|
(error) => new ExpandSdkError({ message: error instanceof Error ? error.message : String(error), cause: error }),
|
|
@@ -165,24 +166,81 @@ export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
169
|
+
* The typed error an undecodable body still identifies itself as.
|
|
170
|
+
*
|
|
171
|
+
* A replica that predates a required field sends a body this client rejects wholesale, so a caller
|
|
172
|
+
* would see `ExpandSdkError` with no status where the previous release gave them
|
|
173
|
+
* `ExpandApiError(429)`. The tag survives on the rejected input, and every arm above keys off the
|
|
174
|
+
* tag rather than the class, so running them against it recovers the status. The `ParseError` stays
|
|
175
|
+
* as the cause: the response really was undecodable and that is what makes the skew visible.
|
|
176
|
+
*
|
|
177
|
+
* Terminates: the recursive call is given a plain object, which cannot itself be a `ParseError`.
|
|
178
|
+
*/
|
|
179
|
+
function recoverUndecodedApiError(error: ParseResult.ParseError, timeoutMs: number): Option.Option<ExpandApiError> {
|
|
180
|
+
if (Option.isNone(parseErrorBodyTag(error))) return Option.none()
|
|
181
|
+
return Option.flatMap(parseErrorBody(error), (body) => {
|
|
182
|
+
const recovered = toExpandError(body, timeoutMs)
|
|
183
|
+
return recovered instanceof ExpandApiError
|
|
184
|
+
? Option.some(new ExpandApiError({ message: recovered.message, status: recovered.status, body, cause: error }))
|
|
185
|
+
: Option.none()
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The server's own verdict, for the error classes that carry one. Statuses whose retryability is
|
|
191
|
+
* ambiguous — 429, 500, 502, 503, 504, 529 — state it outright in the body, so no client-side rule has to
|
|
192
|
+
* guess and a newly-terminal class needs no SDK release.
|
|
193
|
+
*
|
|
194
|
+
* Read through {@link errorField} so the verdict still counts when the body did not decode. That is
|
|
195
|
+
* the case the whole mechanism exists for: an error class this SDK has never heard of is exactly
|
|
196
|
+
* the one no client-side rule can classify, and it is also the one guaranteed to fail the decode.
|
|
197
|
+
* Without the fallback, an unknown `{ retryable: false }` is retried — the precise outcome the
|
|
198
|
+
* field was added to prevent.
|
|
199
|
+
*/
|
|
200
|
+
function declaredRetryable(error: unknown): Option.Option<boolean> {
|
|
201
|
+
return errorField(error, 'retryable', (value): value is boolean => typeof value === 'boolean')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Decide whether a failure should trigger a retry. The body's `retryable` field decides when the
|
|
206
|
+
* server sent one. Everything below it is the fallback for servers that predate the field and for
|
|
207
|
+
* responses that never decoded: timeouts and connection errors are always retryable; API errors are
|
|
208
|
+
* retryable only for the standard transient status codes (408/409/429/5xx).
|
|
170
209
|
*/
|
|
171
210
|
export function isRetryable(error: unknown, timeoutMs: number): boolean {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
return Option.match(declaredRetryable(error), {
|
|
212
|
+
onSome: (retryable) => retryable,
|
|
213
|
+
onNone: () => isRetryableWithoutServerVerdict(error, timeoutMs),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Tags that are terminal on their own, for servers that do not yet say so in the body. Everything
|
|
219
|
+
* here is a verdict rather than a condition: repeating the request reaches the same answer.
|
|
220
|
+
*/
|
|
221
|
+
const TERMINAL_TAGS = new Set([
|
|
222
|
+
'BatchedIdempotencyConflict',
|
|
223
|
+
'BatchedCancellationConflict',
|
|
224
|
+
'FetchNavigationFailed',
|
|
225
|
+
'FetchCaptureFailed',
|
|
226
|
+
// A 504 that is ours says `retryable: false` in the body and never reaches this set. This is the
|
|
227
|
+
// fallback for a server that predates the field: the timeout is the conservative verdict when no
|
|
228
|
+
// result was publishable by the deadline and queue-only expiry was not proven, so retrying it
|
|
229
|
+
// automatically would amplify a control-plane incident. A bare intermediary 504 carries no `_tag`
|
|
230
|
+
// and stays on the normal retryable-status path.
|
|
231
|
+
'FetchCaptureTimeout',
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
function isRetryableWithoutServerVerdict(error: unknown, timeoutMs: number): boolean {
|
|
235
|
+
// Both 502 classes are deterministic verdicts about the target page — the browser committed an
|
|
236
|
+
// internal error document, or the page could not be captured after the retry ladder was spent —
|
|
237
|
+
// not transient gateway failures, so retrying re-runs a full capture for the same outcome.
|
|
238
|
+
// Current servers say so in the body and never reach this line; it stays for the ones that don't
|
|
239
|
+
// yet, whose bodies reach it as a failed decode rather than a value.
|
|
240
|
+
const tag = Option.getOrElse(parseErrorBodyTag(error), () =>
|
|
241
|
+
typeof error === 'object' && error !== null && '_tag' in error && typeof error._tag === 'string' ? error._tag : '',
|
|
242
|
+
)
|
|
243
|
+
if (TERMINAL_TAGS.has(tag)) {
|
|
186
244
|
return false
|
|
187
245
|
}
|
|
188
246
|
// An undecodable response (a transient 5xx with an empty/garbled body, a proxy error page, etc.)
|
|
@@ -234,11 +292,11 @@ export class InternalClient extends Effect.Service<InternalClient>()('@expandai/
|
|
|
234
292
|
): Effect.Effect<FetchJsonResponse, ExpandClientError> =>
|
|
235
293
|
service.fetchJson(params, options).pipe(Effect.mapError((error) => error.toPublicError()))
|
|
236
294
|
|
|
237
|
-
const
|
|
238
|
-
params:
|
|
295
|
+
const getSnapshot = (
|
|
296
|
+
params: SnapshotGetParams,
|
|
239
297
|
options: RequestOptions = {},
|
|
240
|
-
): Effect.Effect<
|
|
241
|
-
service.
|
|
298
|
+
): Effect.Effect<SnapshotCatalog | SnapshotHighlightsResponse, ExpandClientError> =>
|
|
299
|
+
service.snapshots.get(params, options).pipe(Effect.mapError((error) => error.toPublicError()))
|
|
242
300
|
|
|
243
301
|
const batched = (
|
|
244
302
|
params: BatchedParams,
|
|
@@ -258,7 +316,7 @@ export class InternalClient extends Effect.Service<InternalClient>()('@expandai/
|
|
|
258
316
|
): Effect.Effect<CancelBatchedResponse, ExpandClientError> =>
|
|
259
317
|
service.cancelBatched(id, options).pipe(Effect.mapError((error) => error.toPublicError()))
|
|
260
318
|
|
|
261
|
-
return { fetch, fetchJson,
|
|
319
|
+
return { fetch, fetchJson, getSnapshot, batched, getBatched, cancelBatched } as const
|
|
262
320
|
}),
|
|
263
321
|
}) {
|
|
264
322
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Option, ParseResult, Predicate } from 'effect'
|
|
2
|
+
import { ExpandApiError, ExpandClientApiError } from './Error.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The response body a decode failed on.
|
|
6
|
+
*
|
|
7
|
+
* A body that does not satisfy the declared schema arrives as a `ParseError`, which carries none of
|
|
8
|
+
* the body's own fields — not its `_tag`, not its `retryable` verdict, not its `retryAfterSeconds`.
|
|
9
|
+
* Read naively, every one of those looks absent, and absent means "fall back to a client-side
|
|
10
|
+
* guess" at three separate call sites.
|
|
11
|
+
*
|
|
12
|
+
* That is not a hypothetical. This client requires `retryable` on the classes that carry it, so
|
|
13
|
+
* every body from a replica that predates the field fails to decode, and a rolling update runs
|
|
14
|
+
* mixed versions on every deploy. The rejected input survives on the issue, so the fields the
|
|
15
|
+
* server did send are still readable even though the body as a whole was refused.
|
|
16
|
+
*
|
|
17
|
+
* This is deliberately the single place that reaches into a `ParseError` for the body. Each caller
|
|
18
|
+
* that grew its own copy of the check ended up covering a different subset of the fields.
|
|
19
|
+
*/
|
|
20
|
+
export function parseErrorBody(error: unknown): Option.Option<object> {
|
|
21
|
+
if (!ParseResult.isParseError(error)) return Option.none()
|
|
22
|
+
const actual = error.issue.actual
|
|
23
|
+
return typeof actual === 'object' && actual !== null ? Option.some(actual) : Option.none()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A field read off either a decoded error or the raw body of an undecodable one.
|
|
28
|
+
*
|
|
29
|
+
* The decoded case is just the property; the fallback is what keeps a server's own answer
|
|
30
|
+
* authoritative when this client rejected the envelope around it.
|
|
31
|
+
*/
|
|
32
|
+
export function errorField<A>(error: unknown, key: string, refine: Predicate.Refinement<unknown, A>): Option.Option<A> {
|
|
33
|
+
const read = (source: unknown): Option.Option<A> =>
|
|
34
|
+
Predicate.hasProperty(source, key) && refine(source[key]) ? Option.some(source[key]) : Option.none()
|
|
35
|
+
return Option.orElse(read(error), () => {
|
|
36
|
+
if (error instanceof ExpandApiError || error instanceof ExpandClientApiError) {
|
|
37
|
+
const body: unknown = error.body
|
|
38
|
+
return Predicate.hasProperty(body, 'error') ? read(body.error) : read(body)
|
|
39
|
+
}
|
|
40
|
+
return Option.flatMap(parseErrorBody(error), read)
|
|
41
|
+
})
|
|
42
|
+
}
|
package/src/Playground.ts
CHANGED
|
@@ -43,6 +43,54 @@ export function playgroundBase(snapshotId: string, host: string = DEFAULT_PLAYGR
|
|
|
43
43
|
return `${normalizeHost(host)}/s/${encodeURIComponent(snapshotId)}`
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Appendix-only Markdown: `${host}/s/${snapshotId}/appendix` (public, `text/markdown`). */
|
|
47
|
+
export function playgroundAppendixUrl(snapshotId: string, host: string = DEFAULT_PLAYGROUND_HOST): string {
|
|
48
|
+
return `${playgroundBase(snapshotId, host)}/appendix`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One block as Markdown by evidence id: `${host}/s/${snapshotId}/content?id=N`
|
|
53
|
+
* (public, request with `Accept: text/markdown`). Without an id, the whole
|
|
54
|
+
* public Markdown document.
|
|
55
|
+
*/
|
|
56
|
+
export function playgroundContentUrl(
|
|
57
|
+
snapshotId: string,
|
|
58
|
+
evidenceId?: number,
|
|
59
|
+
host: string = DEFAULT_PLAYGROUND_HOST,
|
|
60
|
+
): string {
|
|
61
|
+
const base = `${playgroundBase(snapshotId, host)}/content`
|
|
62
|
+
const id = publicEvidenceId(evidenceId)
|
|
63
|
+
return id === undefined ? base : `${base}?id=${id}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SnapshotHandle {
|
|
67
|
+
readonly snapshotId: string
|
|
68
|
+
/** Origin of the playground URL the handle came from, when it was a URL. */
|
|
69
|
+
readonly host?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SNAPSHOT_ID_PATTERN = /^[A-Za-z0-9._~-]+$/u
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Accept either a bare snapshot id or any playground URL for the snapshot
|
|
76
|
+
* (`https://expand.land/s/<id>`, `/s/<id>/appendix`, `/s/<id>?id=612`) and
|
|
77
|
+
* return the id plus the host it was served from. Text-mode fetch output
|
|
78
|
+
* has no `meta.snapshotId`; the `playground:` front-matter line is the handle.
|
|
79
|
+
*/
|
|
80
|
+
export function parseSnapshotHandle(handle: string): SnapshotHandle | undefined {
|
|
81
|
+
const trimmed = handle.trim()
|
|
82
|
+
if (trimmed.length === 0) return undefined
|
|
83
|
+
if (SNAPSHOT_ID_PATTERN.test(trimmed)) return { snapshotId: trimmed }
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(trimmed)
|
|
86
|
+
const match = /^\/s\/([^/]+)/u.exec(url.pathname)
|
|
87
|
+
if (!match?.[1]) return undefined
|
|
88
|
+
return { snapshotId: decodeURIComponent(match[1]), host: url.origin }
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
46
94
|
/** Origin of a server-provided `meta.playground` URL, for reuse as the host (staging-safe). */
|
|
47
95
|
export function playgroundOrigin(metaPlayground: string): string {
|
|
48
96
|
return new URL(metaPlayground).origin
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Headers, HttpClient } from '@effect/platform'
|
|
2
|
+
import { Duration, Effect, FiberRef, Option, Random } from 'effect'
|
|
3
|
+
import { errorField } from './ParseErrorBody.js'
|
|
4
|
+
|
|
5
|
+
const RETRY_AFTER_HEADER = 'retry-after'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Upper bound on any single wait between attempts, server-requested or our own. `Retry-After` is a
|
|
9
|
+
* hint from a remote we do not control, and honouring an arbitrarily large one would park a
|
|
10
|
+
* caller's request far past its own timeout budget; the same bound applies to the exponential
|
|
11
|
+
* backoff, which a large `maxRetries` would otherwise grow past it. Two minutes is well above the
|
|
12
|
+
* largest value the API sends and well below any plausible caller patience.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_RETRY_AFTER = Duration.minutes(2)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `Retry-After` from the most recent response received on this fiber, or `None` if that response
|
|
18
|
+
* carried no usable value.
|
|
19
|
+
*
|
|
20
|
+
* The header cannot travel on the error: `HttpApiClient`-style decoding fails with the schema
|
|
21
|
+
* instance parsed from the body, and the `HttpClientResponse` — the only thing that has headers —
|
|
22
|
+
* is gone by the time a retry schedule runs. Recording it here keeps the signal reachable without
|
|
23
|
+
* threading a response through every call site. Every response overwrites it, so a value can never
|
|
24
|
+
* outlive the attempt that produced it, and a fiber only ever observes its own requests.
|
|
25
|
+
*/
|
|
26
|
+
const currentRetryAfter = FiberRef.unsafeMake(Option.none<Duration.Duration>())
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rounded to whole milliseconds rather than kept as fractional seconds.
|
|
30
|
+
*
|
|
31
|
+
* `Duration.seconds(1.001)` is representable only in nanoseconds, so Effect backs it with a
|
|
32
|
+
* `bigint`, and arithmetic on a `bigint` duration rejects a fractional operand outright —
|
|
33
|
+
* `Duration.times(d, 1.125)` throws `RangeError: The number 1.125 cannot be converted to a BigInt`.
|
|
34
|
+
* Both callers can produce a fraction: an HTTP-date differs from now by an arbitrary sub-second
|
|
35
|
+
* amount, and `retryAfterSeconds` is a JSON number. Normalizing here keeps every downstream
|
|
36
|
+
* duration millisecond-backed, so the jitter below is plain number arithmetic. Sub-millisecond
|
|
37
|
+
* precision is meaningless for a retry wait.
|
|
38
|
+
*/
|
|
39
|
+
const positiveSeconds = (seconds: number): Option.Option<Duration.Duration> =>
|
|
40
|
+
Number.isFinite(seconds) && seconds > 0
|
|
41
|
+
? Option.some(Duration.min(Duration.millis(Math.round(seconds * 1_000)), MAX_RETRY_AFTER))
|
|
42
|
+
: Option.none()
|
|
43
|
+
|
|
44
|
+
/** Parses both `Retry-After` forms: delta-seconds and an HTTP-date. */
|
|
45
|
+
const parseRetryAfter = (value: string): Option.Option<Duration.Duration> => {
|
|
46
|
+
const trimmed = value.trim()
|
|
47
|
+
if (/^\d+$/.test(trimmed)) return positiveSeconds(Number(trimmed))
|
|
48
|
+
const retryAt = Date.parse(trimmed)
|
|
49
|
+
return Number.isNaN(retryAt) ? Option.none() : positiveSeconds((retryAt - Date.now()) / 1_000)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Records the `Retry-After` of every response the client receives, so {@link retryAfterDelay} can
|
|
54
|
+
* read it back when a failure is about to be retried.
|
|
55
|
+
*/
|
|
56
|
+
export const recordRetryAfter = <E, R>(client: HttpClient.HttpClient.With<E, R>): HttpClient.HttpClient.With<E, R> =>
|
|
57
|
+
HttpClient.tap(
|
|
58
|
+
// Cleared before the request, not just overwritten after it. An attempt that fails at the
|
|
59
|
+
// transport level never produces a response to overwrite the previous one, so without this the
|
|
60
|
+
// next attempt would sleep on a wait some earlier response asked for — a connection reset would
|
|
61
|
+
// inherit a saturated gate's 20s.
|
|
62
|
+
HttpClient.tapRequest(client, () => FiberRef.set(currentRetryAfter, Option.none())),
|
|
63
|
+
(response) =>
|
|
64
|
+
FiberRef.set(
|
|
65
|
+
currentRetryAfter,
|
|
66
|
+
Option.flatMap(Headers.get(response.headers, RETRY_AFTER_HEADER), parseRetryAfter),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads `retryAfterSeconds` off a decoded error body. Errors that can name their own recovery time
|
|
72
|
+
* say so in the body as well as the header; the body wins because it survives proxies that strip
|
|
73
|
+
* headers, and because it is the value the server actually chose for this error rather than for
|
|
74
|
+
* this status.
|
|
75
|
+
*/
|
|
76
|
+
const bodyRetryAfter = (error: unknown): Option.Option<Duration.Duration> =>
|
|
77
|
+
Option.flatMap(
|
|
78
|
+
// Through `errorField` rather than off the error directly, so a body this client could not
|
|
79
|
+
// decode still gets to name its own wait. An unknown error class is both the one whose delay
|
|
80
|
+
// no client-side default can guess and the one certain to fail the decode.
|
|
81
|
+
errorField(error, 'retryAfterSeconds', (value): value is number => typeof value === 'number'),
|
|
82
|
+
positiveSeconds,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Widest multiple of a server-requested wait this will actually sleep. Jitter only ever runs the
|
|
87
|
+
* wait *longer*: the server named the earliest moment worth trying again, so spreading below it
|
|
88
|
+
* would retry sooner than asked, which is the thing the header exists to prevent.
|
|
89
|
+
*/
|
|
90
|
+
const RETRY_AFTER_JITTER = 1.25
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Spreads a server-requested wait across a window so a whole rejected population does not wake at
|
|
94
|
+
* once. The value is a policy constant chosen per status rather than per caller, so every client
|
|
95
|
+
* refused in the same burst is handed the identical number; sleeping it raw would reconverge them
|
|
96
|
+
* on one instant and march them into the same saturated gate together, which is the behaviour the
|
|
97
|
+
* wait was supposed to break up.
|
|
98
|
+
*/
|
|
99
|
+
export const spreadRetryAfter = (retryAfter: Duration.Duration): Effect.Effect<Duration.Duration> =>
|
|
100
|
+
Effect.map(Random.next, (factor) =>
|
|
101
|
+
// Scaled as milliseconds rather than with `Duration.times`, which throws on a fractional
|
|
102
|
+
// factor whenever the duration happens to be nanosecond-backed. `positiveSeconds` already
|
|
103
|
+
// keeps our own values millisecond-backed; doing the arithmetic in numbers means an
|
|
104
|
+
// unrounded duration arriving from anywhere else cannot reintroduce that crash.
|
|
105
|
+
Duration.min(
|
|
106
|
+
Duration.millis(Math.round(Duration.toMillis(retryAfter) * (1 + factor * (RETRY_AFTER_JITTER - 1)))),
|
|
107
|
+
MAX_RETRY_AFTER,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How long the server asked us to wait before retrying this failure — `Duration.zero` when it asked
|
|
113
|
+
* for nothing, which leaves the caller's own backoff untouched.
|
|
114
|
+
*/
|
|
115
|
+
export const retryAfterDelay = (error: unknown): Effect.Effect<Duration.Duration> =>
|
|
116
|
+
Effect.map(FiberRef.get(currentRetryAfter), (fromHeader) =>
|
|
117
|
+
bodyRetryAfter(error).pipe(
|
|
118
|
+
Option.orElse(() => fromHeader),
|
|
119
|
+
Option.getOrElse(() => Duration.zero),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The wait before the next attempt, given the failure and the backoff the schedule computed.
|
|
125
|
+
*
|
|
126
|
+
* A server that asked for a wait gets at least that wait: retrying sooner than it asked only
|
|
127
|
+
* spends the caller's timeout budget on a refusal it already predicted. The floor is re-jittered
|
|
128
|
+
* ({@link spreadRetryAfter}) because the server hands every client refused in the same burst the
|
|
129
|
+
* identical constant. Backoff wins when it is the longer of the two, but never past
|
|
130
|
+
* {@link MAX_RETRY_AFTER} — a large `maxRetries` must not grow the exponential past what we would
|
|
131
|
+
* accept from the server.
|
|
132
|
+
*/
|
|
133
|
+
export const nextRetryDelay = (error: unknown, backoff: Duration.Duration): Effect.Effect<Duration.Duration> =>
|
|
134
|
+
Effect.flatMap(retryAfterDelay(error), (retryAfter) =>
|
|
135
|
+
Duration.greaterThan(retryAfter, backoff)
|
|
136
|
+
? spreadRetryAfter(retryAfter)
|
|
137
|
+
: Effect.succeed(Duration.min(backoff, MAX_RETRY_AFTER)),
|
|
138
|
+
)
|