@goable-io/sdk 0.3.0 → 0.5.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/src/client.ts CHANGED
@@ -5,22 +5,54 @@
5
5
 
6
6
  import { GoableNetworkError, toApiError } from "./errors.ts"
7
7
  import type {
8
+ AdaptationReportRequest,
9
+ AdaptationReportResponse,
10
+ AuditExportQuery,
11
+ AuditExportResponse,
12
+ BindPolicyRequest,
13
+ BindPolicyResponse,
8
14
  BriefingRequest,
9
15
  BriefingResponse,
16
+ CatalogStatsResponse,
10
17
  CounterfactualRequest,
11
18
  CounterfactualResponse,
19
+ CreateStationRequest,
20
+ CreateStationResponse,
12
21
  DecisionRequest,
13
22
  DecisionResponse,
14
23
  DeleteUserDataResult,
24
+ EdgeCaseRequest,
25
+ EdgeCaseResponse,
26
+ EvaluatePolicyResponse,
15
27
  ExplainRequest,
16
28
  ExplainResponse,
29
+ HealthReadyResponse,
17
30
  HealthResponse,
31
+ IdempotencyOptions,
32
+ LegalDocumentKind,
33
+ LegalDocumentResponse,
34
+ ListPoliciesQuery,
35
+ ListPoliciesResponse,
36
+ ListStationsResponse,
37
+ LlmKeyStatus,
38
+ PolicyResponse,
39
+ ProjectionsPortfolioRequest,
40
+ ProjectionsPortfolioResponse,
18
41
  ProjectionsRequest,
19
42
  ProjectionsResponse,
43
+ PublicSignupRequest,
44
+ PublicSignupResponse,
45
+ QuoteByIdResponse,
20
46
  QuoteRequest,
21
47
  QuoteResponse,
48
+ RecentObservationsQuery,
49
+ RecentObservationsResponse,
22
50
  RecommendSpotRequest,
23
51
  RecommendSpotResponse,
52
+ ReportOutcomeRequest,
53
+ ReportOutcomeResponse,
54
+ ScoreDifficultyRequest,
55
+ ScoreDifficultyResponse,
24
56
  ScoreHistoricalRequest,
25
57
  ScoreHistoricalResponse,
26
58
  ScoreMultiRequest,
@@ -31,6 +63,18 @@ import type {
31
63
  ScoreResponse,
32
64
  ScoreSeriesRequest,
33
65
  ScoreSeriesResponse,
66
+ SetLlmKeyRequest,
67
+ SettlePolicyRequest,
68
+ SettlePolicyResponse,
69
+ SubmitObservationsRequest,
70
+ SubmitObservationsResponse,
71
+ SubmitOutcomeRequest,
72
+ SubmitOutcomeResponse,
73
+ SustainabilityIndexQuery,
74
+ SustainabilityIndexResponse,
75
+ UpdateStationRequest,
76
+ UpdateStationResponse,
77
+ VerificationExportQuery,
34
78
  } from "./types.ts"
35
79
 
36
80
  export type FetchLike = (
@@ -85,6 +129,12 @@ export class GoableClient {
85
129
  return this.request<HealthResponse>("GET", "/v1/health")
86
130
  }
87
131
 
132
+ /** Readiness probe (DB + skill lookup + LLM config). Note: a degraded/critical
133
+ * deployment answers `503`, which surfaces here as a {@link GoableApiError}. */
134
+ healthReady(): Promise<HealthReadyResponse> {
135
+ return this.request<HealthReadyResponse>("GET", "/v1/health/ready")
136
+ }
137
+
88
138
  score(input: ScoreRequest): Promise<ScoreResponse> {
89
139
  return this.request<ScoreResponse>("POST", "/v1/score", input)
90
140
  }
@@ -141,6 +191,212 @@ export class GoableClient {
141
191
  return this.request<RecommendSpotResponse>("POST", "/v1/recommend-spot", input)
142
192
  }
143
193
 
194
+ /** L15 — skill-conditioned difficulty grids per scoring dimension (Pro+). */
195
+ scoreDifficulty(input: ScoreDifficultyRequest): Promise<ScoreDifficultyResponse> {
196
+ return this.request<ScoreDifficultyResponse>("POST", "/v1/score/difficulty", input)
197
+ }
198
+
199
+ /** Close the calibration loop: report the observed outcome of a scored
200
+ * session. Requires the `outcomes:write` scope. Pass `idempotencyKey` so a
201
+ * retry after a network timeout can't record the same outcome twice. */
202
+ reportOutcome(
203
+ sessionId: string,
204
+ input: ReportOutcomeRequest,
205
+ options?: IdempotencyOptions,
206
+ ): Promise<ReportOutcomeResponse> {
207
+ return this.request<ReportOutcomeResponse>(
208
+ "POST",
209
+ `/v1/score/${encodeURIComponent(sessionId)}/outcome`,
210
+ input,
211
+ idempotencyHeader(options),
212
+ )
213
+ }
214
+
215
+ /** Report a standalone activity outcome not tied to a scored session — the
216
+ * operator-reported behavioural signal behind the calibration + research
217
+ * datasets. Responds 202. Requires the `outcomes:write` scope. For an
218
+ * outcome linked to a specific score, use {@link reportOutcome} instead. */
219
+ submitOutcome(input: SubmitOutcomeRequest): Promise<SubmitOutcomeResponse> {
220
+ return this.request<SubmitOutcomeResponse>("POST", "/v1/outcomes", input)
221
+ }
222
+
223
+ /** LLM edge-case narrative for a marginal score. */
224
+ edgeCase(input: EdgeCaseRequest): Promise<EdgeCaseResponse> {
225
+ return this.request<EdgeCaseResponse>("POST", "/v1/intelligence/edge-case", input)
226
+ }
227
+
228
+ /** T3 — multi-spot climate-decadal projections (Scale). */
229
+ projectionsPortfolio(input: ProjectionsPortfolioRequest): Promise<ProjectionsPortfolioResponse> {
230
+ return this.request<ProjectionsPortfolioResponse>("POST", "/v1/projections/portfolio", input)
231
+ }
232
+
233
+ /** T3 — adaptation report across months × scenarios × decades (Scale). */
234
+ adaptationReport(input: AdaptationReportRequest): Promise<AdaptationReportResponse> {
235
+ return this.request<AdaptationReportResponse>("POST", "/v1/projections/adaptation-report", input)
236
+ }
237
+
238
+ // ── underwriting policy lifecycle (Scale) ─────────────────────────────────
239
+ /** Fetch a stored quote by id. */
240
+ getQuote(id: string): Promise<QuoteByIdResponse> {
241
+ return this.request<QuoteByIdResponse>("GET", `/v1/underwriting/quote/${encodeURIComponent(id)}`)
242
+ }
243
+
244
+ /**
245
+ * Bind a recent quote into a policy. Responds 201. A watch-level drift event
246
+ * on the resolved cell surfaces as `driftAdvisories` on success; a
247
+ * warning/critical event refuses the bind with `422 DRIFT_ACTIVE`, thrown as
248
+ * a {@link DriftActiveError}.
249
+ */
250
+ bindPolicy(input: BindPolicyRequest, options?: IdempotencyOptions): Promise<BindPolicyResponse> {
251
+ return this.request<BindPolicyResponse>(
252
+ "POST",
253
+ "/v1/underwriting/policy/bind",
254
+ input,
255
+ idempotencyHeader(options),
256
+ )
257
+ }
258
+
259
+ /** List the calling tenant's bound policies (paginated, boundAt DESC). */
260
+ listPolicies(query?: ListPoliciesQuery): Promise<ListPoliciesResponse> {
261
+ return this.request<ListPoliciesResponse>("GET", `/v1/underwriting/policy${toQuery(query)}`)
262
+ }
263
+
264
+ /** Fetch a single policy + its payout events. */
265
+ getPolicy(policyId: string): Promise<PolicyResponse> {
266
+ return this.request<PolicyResponse>("GET", `/v1/underwriting/policy/${encodeURIComponent(policyId)}`)
267
+ }
268
+
269
+ /** Re-evaluate a bound policy against the historical archive; inserts any
270
+ * newly detected payout events. No request body. */
271
+ evaluatePolicy(policyId: string): Promise<EvaluatePolicyResponse> {
272
+ return this.request<EvaluatePolicyResponse>(
273
+ "POST",
274
+ `/v1/underwriting/policy/${encodeURIComponent(policyId)}/evaluate`,
275
+ )
276
+ }
277
+
278
+ /**
279
+ * Settle a bound policy. PLATFORM-OPS ONLY — requires the `platform_admin`
280
+ * scope (a cross-tenant underwriter operation, normally run by the daily
281
+ * settlement cron). Not a policyholder self-service action; tenant
282
+ * integrations should not call this.
283
+ */
284
+ settlePolicy(policyId: string, input: SettlePolicyRequest): Promise<SettlePolicyResponse> {
285
+ return this.request<SettlePolicyResponse>(
286
+ "POST",
287
+ `/v1/underwriting/policy/${encodeURIComponent(policyId)}/settle`,
288
+ input,
289
+ )
290
+ }
291
+
292
+ // ── observations / nowcasting (L5.3) ──────────────────────────────────────
293
+ /** Register a tenant observation station. Responds 201. */
294
+ createStation(input: CreateStationRequest): Promise<CreateStationResponse> {
295
+ return this.request<CreateStationResponse>("POST", "/v1/observations/stations", input)
296
+ }
297
+
298
+ /** List the calling tenant's observation stations. */
299
+ listStations(): Promise<ListStationsResponse> {
300
+ return this.request<ListStationsResponse>("GET", "/v1/observations/stations")
301
+ }
302
+
303
+ /** Patch a station (partial update). */
304
+ updateStation(stationId: string, input: UpdateStationRequest): Promise<UpdateStationResponse> {
305
+ return this.request<UpdateStationResponse>(
306
+ "PATCH",
307
+ `/v1/observations/stations/${encodeURIComponent(stationId)}`,
308
+ input,
309
+ )
310
+ }
311
+
312
+ /** Push station observations into the 0-6h assimilation window (Pro+).
313
+ * Responds 202. */
314
+ submitObservations(input: SubmitObservationsRequest): Promise<SubmitObservationsResponse> {
315
+ return this.request<SubmitObservationsResponse>("POST", "/v1/observations", input)
316
+ }
317
+
318
+ /** Most-recent observations for one of the tenant's stations. */
319
+ recentObservations(
320
+ stationId: string,
321
+ query?: RecentObservationsQuery,
322
+ ): Promise<RecentObservationsResponse> {
323
+ return this.request<RecentObservationsResponse>(
324
+ "GET",
325
+ `/v1/observations/stations/${encodeURIComponent(stationId)}/recent${toQuery(query)}`,
326
+ )
327
+ }
328
+
329
+ // ── public / research (no-auth surfaces) ──────────────────────────────────
330
+ /** Public Goable Sustainability Index (JSON-LD, CC BY 4.0). */
331
+ sustainabilityIndex(query: SustainabilityIndexQuery): Promise<SustainabilityIndexResponse> {
332
+ return this.request<SustainabilityIndexResponse>(
333
+ "GET",
334
+ `/v1/public/sustainability-index${toQuery(query)}`,
335
+ )
336
+ }
337
+
338
+ /** Public Stream F forecast-verification export. Returns the raw NDJSON
339
+ * stream as a string (one cell per line + a trailing meta line). */
340
+ verificationExport(query?: VerificationExportQuery): Promise<string> {
341
+ return this.requestText("GET", `/v1/research/verification/export${toQuery(query)}`)
342
+ }
343
+
344
+ /** Public L15 Difficulty Atlas export. Returns the raw NDJSON stream. */
345
+ difficultyAtlasExport(): Promise<string> {
346
+ return this.requestText("GET", "/v1/research/difficulty-atlas/export.jsonl")
347
+ }
348
+
349
+ /** Self-service tenant signup (no auth). Always 202 on success. */
350
+ publicSignup(input: PublicSignupRequest): Promise<PublicSignupResponse> {
351
+ return this.request<PublicSignupResponse>("POST", "/v1/public/signup", input)
352
+ }
353
+
354
+ /** Open catalogue coverage stats (no auth). */
355
+ catalogStats(): Promise<CatalogStatsResponse> {
356
+ return this.request<CatalogStatsResponse>("GET", "/v1/public/catalog-stats")
357
+ }
358
+
359
+ /** Fetch the current published legal document of a kind (no auth). */
360
+ legalDocument(kind: LegalDocumentKind): Promise<LegalDocumentResponse> {
361
+ return this.request<LegalDocumentResponse>(
362
+ "GET",
363
+ `/v1/legal/${encodeURIComponent(kind)}/current`,
364
+ )
365
+ }
366
+
367
+ // ── audit / compliance ────────────────────────────────────────────────────
368
+ /**
369
+ * Export the calling tenant's own score + outcome audit history for a date
370
+ * range. `format: "csv"` returns the raw CSV as a `string`; the default
371
+ * (`"json"`) returns the parsed {@link AuditExportResponse}. Offset-paginated
372
+ * via `limit` / `offset`.
373
+ */
374
+ auditExport(query: AuditExportQuery & { format: "csv" }): Promise<string>
375
+ auditExport(query: AuditExportQuery & { format?: "json" }): Promise<AuditExportResponse>
376
+ auditExport(query: AuditExportQuery): Promise<AuditExportResponse | string> {
377
+ const path = `/v1/audit/export${toQuery(query)}`
378
+ if (query.format === "csv") return this.requestText("GET", path)
379
+ return this.request<AuditExportResponse>("GET", path)
380
+ }
381
+
382
+ // ── LLM BYOK (bring-your-own Anthropic key) ───────────────────────────────
383
+ /** Set/rotate the tenant's Anthropic API key. The server validates it with
384
+ * one cheap Anthropic call, encrypts it at rest, and never echoes it back.
385
+ * Resolves `void` on the 204. */
386
+ async setLlmKey(input: SetLlmKeyRequest): Promise<void> {
387
+ await this.request<void>("PUT", "/v1/tenant/llm-key", input)
388
+ }
389
+
390
+ /** Get the tenant's Anthropic key status (masked — never the key itself). */
391
+ getLlmKey(): Promise<LlmKeyStatus> {
392
+ return this.request<LlmKeyStatus>("GET", "/v1/tenant/llm-key")
393
+ }
394
+
395
+ /** Remove the tenant's Anthropic key. Resolves `void` on the 204. */
396
+ async deleteLlmKey(): Promise<void> {
397
+ await this.request<void>("DELETE", "/v1/tenant/llm-key")
398
+ }
399
+
144
400
  /** GDPR Art. 17 erasure. Surfaces the receipt headers from a 204. */
145
401
  async deleteUserData(pseudonym: string): Promise<DeleteUserDataResult> {
146
402
  const res = await this.rawRequest("DELETE", `/v1/decision/user-data/${encodeURIComponent(pseudonym)}`)
@@ -162,14 +418,47 @@ export class GoableClient {
162
418
  }
163
419
 
164
420
  // ── transport ─────────────────────────────────────────────────────────────
165
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
166
- const res = await this.rawRequest(method, path, body)
421
+ private async request<T>(
422
+ method: string,
423
+ path: string,
424
+ body?: unknown,
425
+ extraHeaders?: Record<string, string>,
426
+ ): Promise<T> {
427
+ const res = await this.rawRequest(method, path, body, extraHeaders)
167
428
  const parsed = await safeJson(res)
168
- if (!res.ok) throw toApiError(res.status, parsed)
429
+ if (!res.ok) throw toApiError(res.status, parsed, res.headers)
169
430
  return parsed as T
170
431
  }
171
432
 
172
- private async rawRequest(method: string, path: string, body?: unknown): ReturnType<FetchLike> {
433
+ /** Like {@link request} but returns the raw response body as text — used for
434
+ * the NDJSON research streams and the `format=csv` audit export, which are
435
+ * not a single JSON document. */
436
+ private async requestText(method: string, path: string): Promise<string> {
437
+ const res = await this.rawRequest(method, path)
438
+ let text: string
439
+ try {
440
+ text = await res.text()
441
+ } catch (err) {
442
+ throw new GoableNetworkError("Failed to read response body", "parse", err)
443
+ }
444
+ if (!res.ok) {
445
+ let parsed: unknown = text
446
+ try {
447
+ parsed = JSON.parse(text)
448
+ } catch {
449
+ // non-JSON error body — pass the raw text through to toApiError
450
+ }
451
+ throw toApiError(res.status, parsed, res.headers)
452
+ }
453
+ return text
454
+ }
455
+
456
+ private async rawRequest(
457
+ method: string,
458
+ path: string,
459
+ body?: unknown,
460
+ extraHeaders?: Record<string, string>,
461
+ ): ReturnType<FetchLike> {
173
462
  // `X-Goable-Key` rather than `Authorization: Bearer` — the API
174
463
  // sits behind CloudFront with OAC, which hijacks the standard
175
464
  // Authorization header for its own SigV4 signature. Custom header
@@ -181,6 +470,11 @@ export class GoableClient {
181
470
  Accept: "application/json",
182
471
  }
183
472
  if (body !== undefined) headers["Content-Type"] = "application/json"
473
+ if (extraHeaders) {
474
+ for (const [k, v] of Object.entries(extraHeaders)) {
475
+ if (v !== undefined) headers[k] = v
476
+ }
477
+ }
184
478
 
185
479
  const controller = this.timeoutMs > 0 ? new AbortController() : undefined
186
480
  const timer = controller && this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined
@@ -205,6 +499,23 @@ export class GoableClient {
205
499
  }
206
500
  }
207
501
 
502
+ /** Serialise a query object into a leading-`?` string (or "" when empty).
503
+ * Skips undefined/null; isomorphic (URLSearchParams is a Node 18+/browser global). */
504
+ function toQuery(params?: Record<string, unknown>): string {
505
+ if (!params) return ""
506
+ const usp = new URLSearchParams()
507
+ for (const [k, v] of Object.entries(params)) {
508
+ if (v !== undefined && v !== null) usp.append(k, String(v))
509
+ }
510
+ const s = usp.toString()
511
+ return s ? `?${s}` : ""
512
+ }
513
+
514
+ /** Build the optional `Idempotency-Key` header bag from per-call options. */
515
+ function idempotencyHeader(options?: IdempotencyOptions): Record<string, string> | undefined {
516
+ return options?.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : undefined
517
+ }
518
+
208
519
  async function safeJson(res: { text(): Promise<string> }): Promise<unknown> {
209
520
  let text: string
210
521
  try {
package/src/errors.ts CHANGED
@@ -14,8 +14,30 @@ export interface ZodIssueLike {
14
14
  [k: string]: unknown
15
15
  }
16
16
 
17
+ /** Rate-limit snapshot parsed from `X-RateLimit-*` response headers. Present
18
+ * when the server sent them (currently the `score` + `recommend-spot`
19
+ * endpoints; omitted on unlimited Scale plans). */
20
+ export interface RateLimit {
21
+ /** `X-RateLimit-Limit` — daily safety cap for this endpoint + plan. */
22
+ limit: number
23
+ /** `X-RateLimit-Remaining` — requests left in the current window. */
24
+ remaining: number
25
+ /** `X-RateLimit-Reset` — Unix timestamp (seconds) when the window resets. */
26
+ reset: number
27
+ }
28
+
29
+ /** Structured extras attached to a {@link GoableApiError}. */
30
+ export interface ApiErrorExtra {
31
+ issues?: ZodIssueLike[]
32
+ detail?: Record<string, unknown>
33
+ retryAfterSeconds?: number | null
34
+ rateLimit?: RateLimit
35
+ }
36
+
17
37
  export class GoableApiError extends Error {
18
- override readonly name = "GoableApiError"
38
+ // Typed `string` (not the literal) so subclasses like DriftActiveError can
39
+ // override it with their own name.
40
+ override readonly name: string = "GoableApiError"
19
41
  /** HTTP status code. */
20
42
  readonly status: number
21
43
  /** Machine-readable code from the `error` field (e.g. "PAYMENT_REQUIRED"). */
@@ -24,23 +46,47 @@ export class GoableApiError extends Error {
24
46
  readonly issues?: ZodIssueLike[]
25
47
  /** Free-form extra context (e.g. plan info, quote id). */
26
48
  readonly detail?: Record<string, unknown>
49
+ /** Seconds to wait before retrying, from the `Retry-After` header. Set on a
50
+ * `429`; `null` when the header is absent or unparseable. Lets a caller
51
+ * implement a compliant back-off (`if (err.status === 429) sleep(err.retryAfterSeconds)`). */
52
+ readonly retryAfterSeconds: number | null
53
+ /** Rate-limit snapshot from `X-RateLimit-*` headers, when the response carried them. */
54
+ readonly rateLimit?: RateLimit
27
55
 
28
- constructor(
29
- status: number,
30
- code: string,
31
- message?: string,
32
- extra?: { issues?: ZodIssueLike[]; detail?: Record<string, unknown> },
33
- ) {
56
+ constructor(status: number, code: string, message?: string, extra?: ApiErrorExtra) {
34
57
  super(message ?? code)
35
58
  this.status = status
36
59
  this.code = code
37
60
  if (extra?.issues) this.issues = extra.issues
38
61
  if (extra?.detail) this.detail = extra.detail
62
+ this.retryAfterSeconds = extra?.retryAfterSeconds ?? null
63
+ if (extra?.rateLimit) this.rateLimit = extra.rateLimit
39
64
  // Restore prototype chain for `instanceof` across transpilation targets.
40
65
  Object.setPrototypeOf(this, GoableApiError.prototype)
41
66
  }
42
67
  }
43
68
 
69
+ /**
70
+ * Raised on a `422 DRIFT_ACTIVE` from `POST /v1/underwriting/policy/bind`:
71
+ * the resolved cell has an open warning/critical L9 drift event, so the bind
72
+ * is refused (a watch-level event is a soft `driftAdvisories` on success, not
73
+ * an error). Subclasses `GoableApiError`, so existing `instanceof
74
+ * GoableApiError` / `.code === "DRIFT_ACTIVE"` checks keep working.
75
+ */
76
+ export class DriftActiveError extends GoableApiError {
77
+ override readonly name = "DriftActiveError"
78
+ /** The blocking cells, from the server's `detail.openDriftEvents`. Shape is
79
+ * per-cell and best-effort (documented, not part of the typed contract). */
80
+ readonly openDriftEvents: Array<Record<string, unknown>>
81
+
82
+ constructor(status: number, code: string, message?: string, extra?: ApiErrorExtra) {
83
+ super(status, code, message, extra)
84
+ const raw = extra?.detail?.openDriftEvents
85
+ this.openDriftEvents = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : []
86
+ Object.setPrototypeOf(this, DriftActiveError.prototype)
87
+ }
88
+ }
89
+
44
90
  export class GoableNetworkError extends Error {
45
91
  override readonly name = "GoableNetworkError"
46
92
  /** "timeout" when the request was aborted by the configured timeout. */
@@ -62,14 +108,39 @@ interface RawErrorBody {
62
108
  detail?: unknown
63
109
  }
64
110
 
111
+ /** A minimal read-only header bag (the `headers` of a fetch Response). */
112
+ export interface HeaderBag {
113
+ get(name: string): string | null
114
+ }
115
+
116
+ /** Parse an integer header, returning null when absent or non-numeric. */
117
+ function intHeader(headers: HeaderBag | undefined, name: string): number | null {
118
+ const v = headers?.get(name)
119
+ if (v == null) return null
120
+ const n = Number(v)
121
+ return Number.isFinite(n) ? n : null
122
+ }
123
+
65
124
  /** Map a parsed error body + status into a GoableApiError. Tolerant of a
66
- * non-conforming body (falls back to the status code as the error code). */
67
- export function toApiError(status: number, body: unknown): GoableApiError {
125
+ * non-conforming body (falls back to the status code as the error code).
126
+ * When `headers` are supplied, surfaces `Retry-After` (as `retryAfterSeconds`)
127
+ * and the `X-RateLimit-*` snapshot on the resulting error. */
128
+ export function toApiError(status: number, body: unknown, headers?: HeaderBag): GoableApiError {
68
129
  const b = (body ?? {}) as RawErrorBody
69
130
  const code = typeof b.error === "string" ? b.error : `HTTP_${status}`
70
131
  const message = typeof b.message === "string" ? b.message : undefined
71
- const extra: { issues?: ZodIssueLike[]; detail?: Record<string, unknown> } = {}
132
+ const extra: ApiErrorExtra = {}
72
133
  if (Array.isArray(b.issues)) extra.issues = b.issues as ZodIssueLike[]
73
134
  if (b.detail && typeof b.detail === "object") extra.detail = b.detail as Record<string, unknown>
135
+ extra.retryAfterSeconds = intHeader(headers, "Retry-After")
136
+ const limit = intHeader(headers, "X-RateLimit-Limit")
137
+ const remaining = intHeader(headers, "X-RateLimit-Remaining")
138
+ const reset = intHeader(headers, "X-RateLimit-Reset")
139
+ if (limit !== null && remaining !== null && reset !== null) {
140
+ extra.rateLimit = { limit, remaining, reset }
141
+ }
142
+ // Specialise the one error the SDK models with its own class. Stays a
143
+ // GoableApiError subclass, so generic catch sites are unaffected.
144
+ if (code === "DRIFT_ACTIVE") return new DriftActiveError(status, code, message, extra)
74
145
  return new GoableApiError(status, code, message, extra)
75
146
  }