@expandai/sdk 0.17.2 → 0.19.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 +4 -0
- package/build/dts/Client.d.ts +10 -4
- package/build/dts/Client.d.ts.map +1 -1
- package/build/dts/Error.d.ts.map +1 -1
- package/build/dts/FetchJson.d.ts +171 -149
- package/build/dts/FetchJson.d.ts.map +1 -1
- package/build/dts/Generated.d.ts +512 -452
- package/build/dts/Generated.d.ts.map +1 -1
- package/build/dts/HttpClientHelpers.d.ts +1 -1
- package/build/dts/Internal.d.ts +4 -3
- package/build/dts/Internal.d.ts.map +1 -1
- package/build/dts/Playground.d.ts +8 -4
- package/build/dts/Playground.d.ts.map +1 -1
- package/build/dts/Service.d.ts +91 -85
- package/build/dts/Service.d.ts.map +1 -1
- package/build/dts/index.d.ts +1 -1
- package/build/dts/index.d.ts.map +1 -1
- package/build/esm/Client.js +9 -1
- package/build/esm/Client.js.map +1 -1
- package/build/esm/Error.js.map +1 -1
- package/build/esm/FetchJson.js +8 -12
- package/build/esm/FetchJson.js.map +1 -1
- package/build/esm/Generated.js +155 -84
- package/build/esm/Generated.js.map +1 -1
- package/build/esm/Internal.js +38 -9
- package/build/esm/Internal.js.map +1 -1
- package/build/esm/Playground.js +38 -18
- package/build/esm/Playground.js.map +1 -1
- package/build/esm/Service.js +45 -23
- package/build/esm/Service.js.map +1 -1
- package/build/esm/index.js.map +1 -1
- package/package.json +4 -3
- package/src/Client.ts +15 -2
- package/src/FetchJson.ts +8 -12
- package/src/Generated.ts +217 -111
- package/src/Internal.ts +59 -10
- package/src/Playground.ts +54 -20
- package/src/Service.ts +78 -24
- package/src/index.ts +2 -0
package/src/Internal.ts
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
import * as Generated from './Generated.js'
|
|
12
12
|
import {
|
|
13
13
|
type BatchedParams,
|
|
14
|
+
type BatchedRequestOptions,
|
|
14
15
|
type BatchedResponse,
|
|
16
|
+
type CancelBatchedResponse,
|
|
15
17
|
ExpandService,
|
|
16
18
|
type ExpandServiceOptions,
|
|
17
19
|
type FetchJsonParams,
|
|
@@ -25,7 +27,7 @@ import {
|
|
|
25
27
|
type RequestOptions,
|
|
26
28
|
} from './Service.js'
|
|
27
29
|
|
|
28
|
-
const RETRYABLE_STATUS_CODES = HashSet.make(408, 409, 429, 500, 502, 503, 504)
|
|
30
|
+
const RETRYABLE_STATUS_CODES = HashSet.make(408, 409, 429, 500, 502, 503, 504, 529)
|
|
29
31
|
|
|
30
32
|
interface TaggedApiError {
|
|
31
33
|
readonly _tag: string
|
|
@@ -37,9 +39,15 @@ function hasApiErrorTag(error: unknown, tag: string): error is TaggedApiError {
|
|
|
37
39
|
|
|
38
40
|
function apiError(status: number, error: unknown): ExpandApiError {
|
|
39
41
|
const message =
|
|
40
|
-
typeof error === 'object' &&
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
typeof error === 'object' &&
|
|
43
|
+
error !== null &&
|
|
44
|
+
'message' in error &&
|
|
45
|
+
typeof error.message === 'string' &&
|
|
46
|
+
error.message.length > 0
|
|
47
|
+
? error.message
|
|
48
|
+
: typeof error === 'object' && error !== null && '_tag' in error && typeof error._tag === 'string'
|
|
49
|
+
? error._tag
|
|
50
|
+
: `HTTP ${status}`
|
|
43
51
|
return new ExpandApiError({ message, status, body: error, cause: error })
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -49,7 +57,9 @@ function apiError(status: number, error: unknown): ExpandApiError {
|
|
|
49
57
|
* its public error channel.
|
|
50
58
|
*/
|
|
51
59
|
export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
52
|
-
|
|
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(
|
|
53
63
|
Match.when(
|
|
54
64
|
(error: unknown) => error instanceof Cause.TimeoutException,
|
|
55
65
|
(error) => new ExpandTimeoutError({ message: `Request timed out after ${timeoutMs}ms`, timeoutMs, cause: error }),
|
|
@@ -80,6 +90,11 @@ export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
|
80
90
|
(error: unknown) => hasApiErrorTag(error, 'AccessBlocked'),
|
|
81
91
|
(error) => apiError(403, error),
|
|
82
92
|
),
|
|
93
|
+
Match.when(
|
|
94
|
+
(error: unknown) =>
|
|
95
|
+
hasApiErrorTag(error, 'BatchedIdempotencyConflict') || hasApiErrorTag(error, 'BatchedCancellationConflict'),
|
|
96
|
+
(error) => apiError(409, error),
|
|
97
|
+
),
|
|
83
98
|
Match.when(
|
|
84
99
|
(error: unknown) => error instanceof Generated.PayloadTooLarge,
|
|
85
100
|
(error) => new ExpandApiError({ message: 'PayloadTooLarge', status: 413, body: error, cause: error }),
|
|
@@ -96,6 +111,8 @@ export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
|
96
111
|
(error: unknown) => hasApiErrorTag(error, 'TooManyRequests'),
|
|
97
112
|
(error) => apiError(429, error),
|
|
98
113
|
),
|
|
114
|
+
)
|
|
115
|
+
return transportAndClientErrors.pipe(
|
|
99
116
|
Match.when(
|
|
100
117
|
(error: unknown) => error instanceof Generated.InternalError,
|
|
101
118
|
(error) => new ExpandApiError({ message: 'InternalError', status: 500, body: error, cause: error }),
|
|
@@ -105,13 +122,23 @@ export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
|
105
122
|
(error) => apiError(500, error),
|
|
106
123
|
),
|
|
107
124
|
Match.when(
|
|
108
|
-
(error: unknown) => error
|
|
109
|
-
(error) =>
|
|
125
|
+
(error: unknown) => hasApiErrorTag(error, 'FetchNavigationFailed'),
|
|
126
|
+
(error) => apiError(502, error),
|
|
110
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, ...)`.
|
|
111
130
|
Match.when(
|
|
112
131
|
(error: unknown) => hasApiErrorTag(error, 'ServiceUnavailable'),
|
|
113
132
|
(error) => apiError(503, error),
|
|
114
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
|
+
),
|
|
115
142
|
Match.when(
|
|
116
143
|
(error: unknown) => error instanceof Generated.HttpApiDecodeError,
|
|
117
144
|
(error) => new ExpandApiError({ message: 'HttpApiDecodeError', status: 400, body: error, cause: error }),
|
|
@@ -142,6 +169,22 @@ export function toExpandError(error: unknown, timeoutMs: number): ExpandError {
|
|
|
142
169
|
* retryable; API errors are retryable only for the standard transient status codes (408/409/429/5xx).
|
|
143
170
|
*/
|
|
144
171
|
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')) {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
145
188
|
// An undecodable response (a transient 5xx with an empty/garbled body, a proxy error page, etc.)
|
|
146
189
|
// surfaces as a bare ParseError with no HTTP status, so the status-based classification below can't
|
|
147
190
|
// see the 5xx and the request would fail un-retried. Treat decode failures as retryable — the
|
|
@@ -170,7 +213,7 @@ export function isRetryable(error: unknown, timeoutMs: number): boolean {
|
|
|
170
213
|
/**
|
|
171
214
|
* Effect-side bridge between {@link ExpandService} and the Promise-based {@link ExpandClient}.
|
|
172
215
|
*
|
|
173
|
-
* Exposes the same fetch/batched/getBatched methods as `ExpandService` but with the typed `ExpandError`
|
|
216
|
+
* Exposes the same fetch/batched/getBatched/cancelBatched methods as `ExpandService` but with the typed `ExpandError`
|
|
174
217
|
* channel mapped to the `ExpandClientError` hierarchy that async/await consumers expect. Not exported
|
|
175
218
|
* from the package — Effect consumers depend on `ExpandService` directly and receive the typed
|
|
176
219
|
* `ExpandError` unions as-is.
|
|
@@ -199,7 +242,7 @@ export class InternalClient extends Effect.Service<InternalClient>()('@expandai/
|
|
|
199
242
|
|
|
200
243
|
const batched = (
|
|
201
244
|
params: BatchedParams,
|
|
202
|
-
options:
|
|
245
|
+
options: BatchedRequestOptions,
|
|
203
246
|
): Effect.Effect<BatchedResponse, ExpandClientError> =>
|
|
204
247
|
service.batched(params, options).pipe(Effect.mapError((error) => error.toPublicError()))
|
|
205
248
|
|
|
@@ -209,7 +252,13 @@ export class InternalClient extends Effect.Service<InternalClient>()('@expandai/
|
|
|
209
252
|
): Effect.Effect<GetBatchedResponse, ExpandClientError> =>
|
|
210
253
|
service.getBatched(id, options).pipe(Effect.mapError((error) => error.toPublicError()))
|
|
211
254
|
|
|
212
|
-
|
|
255
|
+
const cancelBatched = (
|
|
256
|
+
id: string,
|
|
257
|
+
options: RequestOptions = {},
|
|
258
|
+
): Effect.Effect<CancelBatchedResponse, ExpandClientError> =>
|
|
259
|
+
service.cancelBatched(id, options).pipe(Effect.mapError((error) => error.toPublicError()))
|
|
260
|
+
|
|
261
|
+
return { fetch, fetchJson, fetchSearch, batched, getBatched, cancelBatched } as const
|
|
213
262
|
}),
|
|
214
263
|
}) {
|
|
215
264
|
/**
|
package/src/Playground.ts
CHANGED
|
@@ -3,12 +3,9 @@
|
|
|
3
3
|
// Canonical public snapshot URLs (all public, no auth, no TTL):
|
|
4
4
|
//
|
|
5
5
|
// /s/<snapshotId> — whole snapshot
|
|
6
|
-
// /s/<snapshotId
|
|
7
|
-
// /s/<snapshotId>?id=<evidenceId> — open extracted State JSON evidence
|
|
6
|
+
// /s/<snapshotId>?id=<evidenceId> — open any public evidence target
|
|
8
7
|
//
|
|
9
|
-
// A citation maps a search snippet
|
|
10
|
-
|
|
11
|
-
import { Match } from 'effect'
|
|
8
|
+
// A citation maps a search snippet location to the view that audits it.
|
|
12
9
|
|
|
13
10
|
export const DEFAULT_PLAYGROUND_HOST = 'https://expand.land'
|
|
14
11
|
|
|
@@ -16,7 +13,12 @@ export type CitationSource = 'markdown' | 'appendix' | 'statejson'
|
|
|
16
13
|
|
|
17
14
|
/** Snippet location subset needed to build a deep link. Tolerates `null` and `undefined`. */
|
|
18
15
|
export interface CitationLocation {
|
|
16
|
+
readonly evidenceId?: number | null
|
|
17
|
+
/** @deprecated Use evidenceId. */
|
|
18
|
+
readonly markdownBlockId?: string | null
|
|
19
|
+
/** @deprecated Use evidenceId for public citations. */
|
|
19
20
|
readonly nodeIds?: ReadonlyArray<number> | null
|
|
21
|
+
/** @deprecated Use evidenceId. */
|
|
20
22
|
readonly stateJsonSourceId?: number | null
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -24,6 +26,18 @@ function normalizeHost(host: string): string {
|
|
|
24
26
|
return host.replace(/\/+$/, '')
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function publicEvidenceId(value: number | null | undefined): number | undefined {
|
|
30
|
+
return value !== null && value !== undefined && Number.isInteger(value) && value > 0 && value <= 2_147_483_647
|
|
31
|
+
? value
|
|
32
|
+
: undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function legacyArchiveId(value: number | null | undefined): number | undefined {
|
|
36
|
+
return value !== null && value !== undefined && Number.isInteger(value) && value >= 0 && value <= 0xffffffff
|
|
37
|
+
? value
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
/** Whole-snapshot URL: `${host}/s/${snapshotId}`. */
|
|
28
42
|
export function playgroundBase(snapshotId: string, host: string = DEFAULT_PLAYGROUND_HOST): string {
|
|
29
43
|
return `${normalizeHost(host)}/s/${encodeURIComponent(snapshotId)}`
|
|
@@ -51,26 +65,46 @@ export function resolvePlaygroundHost(metaPlayground: string | null | undefined,
|
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/**
|
|
54
|
-
* Deep link auditing a single snippet
|
|
55
|
-
* - `statejson` with a source id → `?id=<stateJsonSourceId>` (opens the JSON evidence object).
|
|
56
|
-
* - `markdown`/`appendix` with a node id → `/playground#focus=<nodeIds[0]>` (highlights the block).
|
|
57
|
-
* - otherwise → the whole-snapshot base URL.
|
|
68
|
+
* Deep link auditing a single snippet by its canonical public Evidence ID.
|
|
58
69
|
*/
|
|
70
|
+
export function citationUrl(snapshotId: string, location: CitationLocation | null | undefined, host?: string): string
|
|
71
|
+
/** @deprecated Pass the location as the second argument; source is no longer needed. */
|
|
59
72
|
export function citationUrl(
|
|
60
73
|
snapshotId: string,
|
|
61
74
|
source: CitationSource,
|
|
62
75
|
location: CitationLocation | null | undefined,
|
|
63
|
-
host
|
|
76
|
+
host?: string,
|
|
77
|
+
): string
|
|
78
|
+
export function citationUrl(
|
|
79
|
+
snapshotId: string,
|
|
80
|
+
sourceOrLocation: CitationSource | CitationLocation | null | undefined,
|
|
81
|
+
locationOrHost?: CitationLocation | string | null,
|
|
82
|
+
legacyHost: string = DEFAULT_PLAYGROUND_HOST,
|
|
64
83
|
): string {
|
|
84
|
+
const legacySignature = typeof sourceOrLocation === 'string'
|
|
85
|
+
const source = legacySignature ? sourceOrLocation : undefined
|
|
86
|
+
const location = legacySignature
|
|
87
|
+
? typeof locationOrHost === 'string'
|
|
88
|
+
? undefined
|
|
89
|
+
: locationOrHost
|
|
90
|
+
: sourceOrLocation
|
|
91
|
+
const host = legacySignature
|
|
92
|
+
? legacyHost
|
|
93
|
+
: typeof locationOrHost === 'string'
|
|
94
|
+
? locationOrHost
|
|
95
|
+
: DEFAULT_PLAYGROUND_HOST
|
|
65
96
|
const base = playgroundBase(snapshotId, host)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
const evidenceId = publicEvidenceId(location?.evidenceId)
|
|
98
|
+
if (evidenceId !== undefined) {
|
|
99
|
+
return `${base}?id=${evidenceId}`
|
|
100
|
+
}
|
|
101
|
+
if (source === 'statejson') {
|
|
102
|
+
const legacyId = legacyArchiveId(location?.stateJsonSourceId)
|
|
103
|
+
return legacyId === undefined ? base : `${base}?id=${legacyId}`
|
|
104
|
+
}
|
|
105
|
+
if (source === 'markdown' || source === 'appendix') {
|
|
106
|
+
const nodeId = legacyArchiveId(location?.nodeIds?.[0])
|
|
107
|
+
return nodeId === undefined ? base : `${base}/playground#focus=${nodeId}`
|
|
108
|
+
}
|
|
109
|
+
return base
|
|
76
110
|
}
|
package/src/Service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from '@effect/platform'
|
|
2
2
|
import { NodeHttpClient } from '@effect/platform-node'
|
|
3
|
-
import { Context, Duration, Effect, flow, Layer, Schedule, Schema } from 'effect'
|
|
3
|
+
import { Context, Duration, Effect, flow, Layer, Option, Random, Schedule, Schema } from 'effect'
|
|
4
4
|
import { ExpandSdkError } from './Error.js'
|
|
5
5
|
import {
|
|
6
6
|
type FetchObjectModeResultWithJson,
|
|
@@ -31,29 +31,28 @@ export class ExpandServiceOptions extends Schema.Class<ExpandServiceOptions>('Ex
|
|
|
31
31
|
}),
|
|
32
32
|
/**
|
|
33
33
|
* Total wall-clock budget in milliseconds for the operation, including retries and backoff.
|
|
34
|
-
*
|
|
34
|
+
* A successful attempt may use the remaining operation budget. Defaults to 60_000.
|
|
35
35
|
*/
|
|
36
36
|
timeoutMs: Schema.optional(Schema.Number).annotations({
|
|
37
37
|
description:
|
|
38
38
|
'Total wall-clock budget in milliseconds for the operation, including retries and backoff. ' +
|
|
39
|
-
'
|
|
39
|
+
'A successful attempt may use the remaining operation budget. Defaults to 60_000.',
|
|
40
40
|
}),
|
|
41
41
|
/**
|
|
42
42
|
* Number of retry attempts for retryable failures (timeouts, connection errors, 408/409/429/5xx).
|
|
43
|
-
* Defaults to 2.
|
|
44
|
-
* per-call to opt in.
|
|
43
|
+
* Defaults to 2.
|
|
45
44
|
*/
|
|
46
45
|
maxRetries: Schema.optional(Schema.Number).annotations({
|
|
47
46
|
description:
|
|
48
47
|
'Number of retry attempts for retryable failures (timeouts, connection errors, 408/409/429/5xx). ' +
|
|
49
|
-
'Defaults to 2.
|
|
48
|
+
'Defaults to 2.',
|
|
50
49
|
}),
|
|
51
50
|
},
|
|
52
51
|
{ description: 'Options for constructing an `ExpandService` layer or `ExpandClient` instance.' },
|
|
53
52
|
) {}
|
|
54
53
|
|
|
55
54
|
/**
|
|
56
|
-
* Per-request overrides accepted by `ExpandService.fetch`/`batched`/`getBatched` (and, transitively,
|
|
55
|
+
* Per-request overrides accepted by `ExpandService.fetch`/`batched`/`getBatched`/`cancelBatched` (and, transitively,
|
|
57
56
|
* by `ExpandClient`'s methods).
|
|
58
57
|
*/
|
|
59
58
|
export class RequestOptions extends Schema.Class<RequestOptions>('RequestOptions')(
|
|
@@ -82,8 +81,27 @@ export type FetchSearchParams = typeof Generated.FetchFetchSearchRequest.Encoded
|
|
|
82
81
|
export type FetchSearchResponse = FetchSnapshotSearchResultWithJson
|
|
83
82
|
export type BatchedParams = typeof Generated.FetchBatchedRequest.Encoded
|
|
84
83
|
export type BatchedResponse = typeof Generated.BatchedRun.Type
|
|
84
|
+
export interface BatchedRequestOptions extends RequestOptions {
|
|
85
|
+
readonly idempotencyKey?: string
|
|
86
|
+
/**
|
|
87
|
+
* Body fields the public contract does not declare, merged verbatim into the
|
|
88
|
+
* request payload.
|
|
89
|
+
*
|
|
90
|
+
* The API accepts a small number of options that are deliberately absent from
|
|
91
|
+
* the published OpenAPI document and refused for any organization not on the
|
|
92
|
+
* server's internal allowlist. Because the generated request schema is built
|
|
93
|
+
* from that same public document, it drops undeclared keys on encode — so an
|
|
94
|
+
* internal caller passing one through `params` would get a request that looks
|
|
95
|
+
* correct and carries nothing. This is the supported way to send one.
|
|
96
|
+
*
|
|
97
|
+
* Not for application code: anything here is unversioned, unvalidated by the
|
|
98
|
+
* SDK, and rejected by the API unless the calling organization is allowlisted.
|
|
99
|
+
*/
|
|
100
|
+
readonly internalOverrides?: Readonly<Record<string, unknown>>
|
|
101
|
+
}
|
|
85
102
|
export type GetBatchedParams = typeof Generated.FetchGetBatchedParams.Encoded
|
|
86
103
|
export type GetBatchedResponse = typeof Generated.BatchedStatus.Type
|
|
104
|
+
export type CancelBatchedResponse = typeof Generated.BatchedCancellation.Type
|
|
87
105
|
|
|
88
106
|
class ServiceConfig extends Schema.Class<ServiceConfig>('ExpandServiceConfig')({
|
|
89
107
|
apiKey: Schema.NonEmptyString,
|
|
@@ -160,7 +178,10 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
|
|
|
160
178
|
'413': (response) => decodeError(response, Generated.PayloadTooLarge),
|
|
161
179
|
'429': (response) => decodeError(response, Generated.TooManyRequests),
|
|
162
180
|
'500': (response) => decodeError(response, Generated.InternalError),
|
|
181
|
+
'502': (response) => decodeError(response, Generated.FetchNavigationFailed),
|
|
163
182
|
'503': (response) => decodeError(response, Generated.ServiceUnavailable),
|
|
183
|
+
'504': (response) => decodeError(response, Generated.FetchCaptureTimeout),
|
|
184
|
+
'529': (response) => decodeError(response, Generated.FetchCapacityTimeout),
|
|
164
185
|
orElse: (response) => unexpectedStatus(request, response),
|
|
165
186
|
}),
|
|
166
187
|
),
|
|
@@ -175,20 +196,14 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
|
|
|
175
196
|
) {
|
|
176
197
|
const totalTimeoutMs = requestOptions.timeoutMs ?? config.timeoutMs
|
|
177
198
|
const maxRetries = requestOptions.maxRetries ?? config.maxRetries
|
|
178
|
-
// `timeoutMs` is
|
|
179
|
-
//
|
|
180
|
-
// including retry backoff. Pipe ordering matters: per-attempt timeout must wrap the operation
|
|
181
|
-
// (so retry can catch its TimeoutException), and total timeout must wrap retry (so it caps
|
|
182
|
-
// backoff between attempts). Floor with a 1ms minimum so degenerate inputs don't produce a
|
|
183
|
-
// zero-millisecond budget that fires before any work happens.
|
|
184
|
-
const perAttemptTimeoutMs = Math.max(1, Math.floor(totalTimeoutMs / (maxRetries + 1)))
|
|
199
|
+
// `timeoutMs` is one wall-clock budget for the operation, including retries and backoff. A slow
|
|
200
|
+
// valid capture may use most of that budget; only failures that arrive before it expires can retry.
|
|
185
201
|
const retrySchedule = Schedule.exponential(Duration.millis(250)).pipe(
|
|
186
202
|
Schedule.jittered,
|
|
187
203
|
Schedule.compose(Schedule.recurs(maxRetries)),
|
|
188
204
|
Schedule.whileInput((error: unknown) => isRetryable(error, totalTimeoutMs)),
|
|
189
205
|
)
|
|
190
206
|
return yield* operation.pipe(
|
|
191
|
-
Effect.timeout(Duration.millis(perAttemptTimeoutMs)),
|
|
192
207
|
Effect.retry(retrySchedule),
|
|
193
208
|
Effect.timeout(Duration.millis(totalTimeoutMs)),
|
|
194
209
|
Effect.mapError((error) => toExpandError(error, totalTimeoutMs)),
|
|
@@ -226,15 +241,47 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
|
|
|
226
241
|
|
|
227
242
|
const batched = Effect.fn('ExpandService.batched')(function* (
|
|
228
243
|
params: BatchedParams,
|
|
229
|
-
requestOptions:
|
|
244
|
+
requestOptions: BatchedRequestOptions = {},
|
|
230
245
|
) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
246
|
+
const { idempotencyKey: providedIdempotencyKey, ...retryOptions } = requestOptions
|
|
247
|
+
const idempotencyKey = yield* Option.fromNullable(providedIdempotencyKey).pipe(
|
|
248
|
+
Option.match({
|
|
249
|
+
onSome: Effect.succeed,
|
|
250
|
+
onNone: () =>
|
|
251
|
+
Effect.all([Random.nextInt, Random.nextInt, Random.nextInt]).pipe(
|
|
252
|
+
Effect.map(
|
|
253
|
+
([first, second, third]) =>
|
|
254
|
+
`sdk-${Math.abs(first).toString(36)}-${Math.abs(second).toString(36)}-${Math.abs(third).toString(36)}`,
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
}),
|
|
258
|
+
)
|
|
259
|
+
// Merged after the generated payload because the generated schema drops any
|
|
260
|
+
// key it does not declare — silently, which is the dangerous part. See
|
|
261
|
+
// `BatchedRequestOptions.internalOverrides` for why it is not declared.
|
|
262
|
+
//
|
|
263
|
+
// A collision is refused rather than resolved: this hatch exists to add
|
|
264
|
+
// undeclared fields, and letting it win over `urls` or `include` would
|
|
265
|
+
// mean a request could quietly fetch something other than what the caller
|
|
266
|
+
// passed.
|
|
267
|
+
const overrides = requestOptions.internalOverrides ?? {}
|
|
268
|
+
// Checked against the schema's declared fields, not against the keys this
|
|
269
|
+
// particular call happened to set: an omitted-but-declared field like
|
|
270
|
+
// `include` could otherwise be supplied here and bypass the generated
|
|
271
|
+
// contract entirely.
|
|
272
|
+
const collisions = Object.keys(overrides).filter((key) =>
|
|
273
|
+
Object.hasOwn(Generated.FetchBatchedRequest.fields, key),
|
|
274
|
+
)
|
|
275
|
+
if (collisions.length > 0) {
|
|
276
|
+
return yield* new ExpandSdkError({
|
|
277
|
+
message: `internalOverrides may not replace declared request fields: ${collisions.join(', ')}`,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
const generatedRequest: Parameters<typeof generated.fetchBatched>[0] = {
|
|
281
|
+
params: { 'x-idempotency-key': idempotencyKey },
|
|
282
|
+
payload: { ...params, ...overrides },
|
|
283
|
+
}
|
|
284
|
+
return yield* withRetries(generated.fetchBatched(generatedRequest), retryOptions)
|
|
238
285
|
})
|
|
239
286
|
|
|
240
287
|
const getBatched = Effect.fn('ExpandService.getBatched')(function* (
|
|
@@ -247,7 +294,14 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
|
|
|
247
294
|
return yield* withRetries(generated.fetchGetBatched(id, params), { timeoutMs, maxRetries })
|
|
248
295
|
})
|
|
249
296
|
|
|
250
|
-
|
|
297
|
+
const cancelBatched = Effect.fn('ExpandService.cancelBatched')(function* (
|
|
298
|
+
id: string,
|
|
299
|
+
requestOptions: RequestOptions = {},
|
|
300
|
+
) {
|
|
301
|
+
return yield* withRetries(generated.fetchCancelBatched(id), requestOptions)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
return { fetch, fetchJson, fetchSearch, batched, getBatched, cancelBatched } as const
|
|
251
305
|
}),
|
|
252
306
|
}) {
|
|
253
307
|
/**
|