@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.
Files changed (72) hide show
  1. package/README.md +130 -4
  2. package/build/dts/AppendixMarkers.d.ts +30 -0
  3. package/build/dts/AppendixMarkers.d.ts.map +1 -0
  4. package/build/dts/Client.d.ts +41 -6
  5. package/build/dts/Client.d.ts.map +1 -1
  6. package/build/dts/Crawls.d.ts +568 -0
  7. package/build/dts/Crawls.d.ts.map +1 -0
  8. package/build/dts/EnvProxyHttpClient.d.ts +28 -0
  9. package/build/dts/EnvProxyHttpClient.d.ts.map +1 -0
  10. package/build/dts/FetchJson.d.ts +444 -37
  11. package/build/dts/FetchJson.d.ts.map +1 -1
  12. package/build/dts/Generated.d.ts +1545 -788
  13. package/build/dts/Generated.d.ts.map +1 -1
  14. package/build/dts/HttpClientHelpers.d.ts +13 -0
  15. package/build/dts/HttpClientHelpers.d.ts.map +1 -1
  16. package/build/dts/Internal.d.ts +6 -4
  17. package/build/dts/Internal.d.ts.map +1 -1
  18. package/build/dts/ParseErrorBody.d.ts +26 -0
  19. package/build/dts/ParseErrorBody.d.ts.map +1 -0
  20. package/build/dts/Playground.d.ts +20 -0
  21. package/build/dts/Playground.d.ts.map +1 -1
  22. package/build/dts/RetryAfter.d.ts +40 -0
  23. package/build/dts/RetryAfter.d.ts.map +1 -0
  24. package/build/dts/Service.d.ts +94 -45
  25. package/build/dts/Service.d.ts.map +1 -1
  26. package/build/dts/StreamPage.d.ts +10 -0
  27. package/build/dts/StreamPage.d.ts.map +1 -0
  28. package/build/dts/index.d.ts +9 -2
  29. package/build/dts/index.d.ts.map +1 -1
  30. package/build/esm/AppendixMarkers.js +307 -0
  31. package/build/esm/AppendixMarkers.js.map +1 -0
  32. package/build/esm/Client.js +96 -15
  33. package/build/esm/Client.js.map +1 -1
  34. package/build/esm/Crawls.js +786 -0
  35. package/build/esm/Crawls.js.map +1 -0
  36. package/build/esm/EnvProxyHttpClient.js +55 -0
  37. package/build/esm/EnvProxyHttpClient.js.map +1 -0
  38. package/build/esm/FetchJson.js +48 -22
  39. package/build/esm/FetchJson.js.map +1 -1
  40. package/build/esm/Generated.js +540 -112
  41. package/build/esm/Generated.js.map +1 -1
  42. package/build/esm/HttpClientHelpers.js +36 -1
  43. package/build/esm/HttpClientHelpers.js.map +1 -1
  44. package/build/esm/Internal.js +140 -33
  45. package/build/esm/Internal.js.map +1 -1
  46. package/build/esm/ParseErrorBody.js +41 -0
  47. package/build/esm/ParseErrorBody.js.map +1 -0
  48. package/build/esm/Playground.js +38 -0
  49. package/build/esm/Playground.js.map +1 -1
  50. package/build/esm/RetryAfter.js +104 -0
  51. package/build/esm/RetryAfter.js.map +1 -0
  52. package/build/esm/Service.js +96 -17
  53. package/build/esm/Service.js.map +1 -1
  54. package/build/esm/StreamPage.js +46 -0
  55. package/build/esm/StreamPage.js.map +1 -0
  56. package/build/esm/index.js +5 -1
  57. package/build/esm/index.js.map +1 -1
  58. package/package.json +8 -2
  59. package/src/AppendixMarkers.ts +370 -0
  60. package/src/Client.ts +131 -16
  61. package/src/Crawls.ts +1387 -0
  62. package/src/EnvProxyHttpClient.ts +71 -0
  63. package/src/FetchJson.ts +63 -25
  64. package/src/Generated.ts +738 -162
  65. package/src/HttpClientHelpers.ts +48 -1
  66. package/src/Internal.ts +163 -105
  67. package/src/ParseErrorBody.ts +42 -0
  68. package/src/Playground.ts +48 -0
  69. package/src/RetryAfter.ts +138 -0
  70. package/src/Service.ts +208 -25
  71. package/src/StreamPage.ts +47 -0
  72. package/src/index.ts +29 -3
@@ -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
- return Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)(response), Effect.fail)
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 * as Generated from './Generated.js'
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
- interface TaggedApiError {
33
- readonly _tag: string
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
- function hasApiErrorTag(error: unknown, tag: string): error is TaggedApiError {
37
- return typeof error === 'object' && error !== null && '_tag' in error && error._tag === tag
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
- // Split across two `pipe` calls: the arm list is longer than `pipe`'s 20-argument overloads.
61
- // Arms are evaluated top to bottom across both halves, so the ordering is unaffected.
62
- const transportAndClientErrors = Match.value(error).pipe(
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 instanceof Generated.AuthFailed,
83
- (error) => new ExpandApiError({ message: 'AuthFailed', status: 401, body: error, cause: 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) => new ExpandSdkError({ message: 'Failed to decode the API response', cause: 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
- * Decide whether a failure should trigger a retry. Timeouts and connection errors are always
169
- * retryable; API errors are retryable only for the standard transient status codes (408/409/429/5xx).
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
- if (hasApiErrorTag(error, 'BatchedIdempotencyConflict') || hasApiErrorTag(error, 'BatchedCancellationConflict')) {
173
- return false
174
- }
175
- // A 502 FetchNavigationFailed is a deterministic verdict about the target page (the browser
176
- // committed an internal error document), not a transient gateway failure — retrying re-runs a
177
- // full capture for the same outcome.
178
- if (hasApiErrorTag(error, 'FetchNavigationFailed')) {
179
- return false
180
- }
181
- // A typed 504 FetchCaptureTimeout is the conservative fallback when no result was publishable by
182
- // the request deadline and queue-only expiry was not proven. Automatic retry remains disabled so
183
- // ambiguous timeouts do not amplify a control-plane incident. A deliberate retry may still be
184
- // appropriate; a bare intermediary 504 stays on the normal retryable-status path.
185
- if (hasApiErrorTag(error, 'FetchCaptureTimeout')) {
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 fetchSearch = (
238
- params: FetchSearchParams,
295
+ const getSnapshot = (
296
+ params: SnapshotGetParams,
239
297
  options: RequestOptions = {},
240
- ): Effect.Effect<FetchSearchResponse, ExpandClientError> =>
241
- service.fetchSearch(params, options).pipe(Effect.mapError((error) => error.toPublicError()))
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, fetchSearch, batched, getBatched, cancelBatched } as const
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
+ )