@distilled.cloud/core 0.16.2 → 0.16.3

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
@@ -25,11 +25,13 @@
25
25
  * const result = yield* fn({ organization: "my-org" });
26
26
  * ```
27
27
  */
28
- import * as Duration from "effect/Duration";
28
+ import * as Context from "effect/Context";
29
29
  import * as Effect from "effect/Effect";
30
+ import { pipe } from "effect/Function";
31
+ import * as Option from "effect/Option";
30
32
  import { pipeArguments } from "effect/Pipeable";
31
33
  import { MinimumLogLevel } from "effect/References";
32
- import * as Schedule from "effect/Schedule";
34
+ import * as Ref from "effect/Ref";
33
35
  import * as Schema from "effect/Schema";
34
36
  import * as AST from "effect/SchemaAST";
35
37
  import * as Stream from "effect/Stream";
@@ -45,7 +47,7 @@ import {
45
47
  type PaginatedTrait,
46
48
  type PaginationStrategy,
47
49
  } from "./pagination.ts";
48
- import * as Category from "./category.ts";
50
+ import { makeDefault, type Policy as RetryPolicy } from "./retry.ts";
49
51
  import * as Traits from "./traits.ts";
50
52
  import { getPath } from "./traits.ts";
51
53
 
@@ -143,11 +145,21 @@ export interface ClientConfig<Creds> {
143
145
  * Should return Effect.fail(error) for known errors,
144
146
  * or Effect.fail(fallbackError) for unknown errors.
145
147
  * The optional `errors` parameter provides per-operation typed error classes.
148
+ * The optional `headers` parameter is the response header bag (lowercase
149
+ * keys) — for retryable status codes, pass `retryAfter: parseRetryAfterForStatus(status, headers)`
150
+ * from `@distilled.cloud/core/retry-after` when a standard `Retry-After` /
151
+ * `RateLimit` hint is present; omit `retryAfter` when there is no hint (the
152
+ * default retry policy still uses exponential backoff). The status-gated
153
+ * helper avoids attaching stale `retryAfter` to non-retryable classes
154
+ * (BadRequest/401/404/etc.). The maximum honored hint is capped (default
155
+ * 60s) — override with \`DISTILLED_SERVER_RETRY_HINT_CAP_MS\` or provide
156
+ * \`ServerRetryHintCapMs\` via \`Layer\` from \`@distilled.cloud/core/retry\`.
146
157
  */
147
158
  matchError: (
148
159
  status: number,
149
160
  body: unknown,
150
161
  errors?: readonly ApiErrorClass[],
162
+ headers?: Record<string, string | undefined>,
151
163
  ) => Effect.Effect<never, unknown>;
152
164
 
153
165
  /** Parse error class for schema decode failures */
@@ -169,6 +181,20 @@ export interface ClientConfig<Creds> {
169
181
  pathTemplate: string;
170
182
  parts: Traits.RequestParts;
171
183
  }) => Traits.RequestParts;
184
+
185
+ /**
186
+ * The SDK's `Retry` Context.Service tag. Each per-SDK client wires its
187
+ * own tag here so callers can install a blanket policy at the layer
188
+ * level (e.g. `myEffect.pipe(Cloudflare.Retry.transient)`) and have
189
+ * every API call below it pick it up — same pattern as
190
+ * `packages/aws/src/client/api.ts`.
191
+ *
192
+ * `makeAPI` reads the policy via `Effect.serviceOption(retry)` on every
193
+ * call and falls back to `Retry.makeDefault` (transient/throttling/server
194
+ * with capped exponential backoff + jitter, 5 attempts) when no policy
195
+ * is provided.
196
+ */
197
+ retry: Context.Key<any, RetryPolicy>;
172
198
  }
173
199
 
174
200
  /**
@@ -408,19 +434,6 @@ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
408
434
 
409
435
  const method = httpTrait.method;
410
436
 
411
- // Capped exponential backoff bounded to 8 attempts so a
412
- // pathologically rate-limited endpoint can't hang a test run.
413
- // Effect 4's `Schedule.exponential(base, factor)` already produces
414
- // delays of base, base*factor, base*factor^2, ...; combined with
415
- // `Schedule.both(Schedule.recurs(8))` to cap retries. We don't
416
- // currently honour Retry-After (would require a per-attempt Ref);
417
- // the exponential ramp is conservative enough for the rate limits
418
- // we hit in practice.
419
- const throttlingRetrySchedule = Schedule.both(
420
- Schedule.exponential(Duration.seconds(1), 2),
421
- Schedule.recurs(8),
422
- );
423
-
424
437
  const spanName = `${method} ${httpTrait.path}`;
425
438
 
426
439
  const innerFn = (input: Input): Effect.Effect<any, any, any> =>
@@ -603,6 +616,7 @@ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
603
616
  response.status,
604
617
  errorBody,
605
618
  opConfig.errors,
619
+ response.headers,
606
620
  );
607
621
  }
608
622
 
@@ -651,6 +665,7 @@ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
651
665
  response.status,
652
666
  envelope,
653
667
  opConfig.errors,
668
+ response.headers,
654
669
  );
655
670
  }
656
671
  responseBody = envelope?.data ?? null;
@@ -697,18 +712,39 @@ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
697
712
  );
698
713
  });
699
714
 
700
- // Auto-retry whenever the SDK's matchError returns a typed error
701
- // tagged with the throttling category (e.g. `TooManyRequests`,
702
- // or any SDK-specific subclass marked with `withThrottlingError`
703
- // / `withRetryable({ throttling: true })`). After retries are
704
- // exhausted the original typed error propagates to the caller as
705
- // usual.
715
+ // Auto-retry every operation using the SDK's per-client `Retry`
716
+ // Context.Service. The policy is read with `Effect.serviceOption`
717
+ // and falls back to `Retry.makeDefault` (transient/throttling/server
718
+ // errors with capped exponential backoff + jitter, 5 attempts) when
719
+ // no policy has been provided in context. This mirrors the AWS
720
+ // pattern in `packages/aws/src/client/api.ts` and lets callers
721
+ // install a blanket policy at the layer level instead of wrapping
722
+ // every call site with `Effect.retry(...)`.
723
+ const retryTag = config.retry;
706
724
  const fn = (input: Input): Effect.Effect<any, any, any> => {
707
- const withRetry = innerFn(input).pipe(
708
- Effect.retry({
709
- schedule: throttlingRetrySchedule,
710
- while: (e) => Category.isThrottling(e),
711
- }),
725
+ const withRetry = Effect.gen(function* () {
726
+ const lastError = yield* Ref.make<unknown>(undefined);
727
+ const policy = (yield* Effect.serviceOption(retryTag)).pipe(
728
+ Option.map((value) =>
729
+ typeof value === "function" ? value(lastError) : value,
730
+ ),
731
+ Option.getOrElse(() => makeDefault(lastError)),
732
+ );
733
+
734
+ return yield* pipe(
735
+ innerFn(input),
736
+ Effect.tapError((error) => Ref.set(lastError, error)),
737
+ policy.while
738
+ ? (eff) =>
739
+ Effect.retry(eff, {
740
+ while: policy.while,
741
+ schedule: policy.schedule,
742
+ })
743
+ : (eff) => eff,
744
+ );
745
+ });
746
+
747
+ const withSpan = withRetry.pipe(
712
748
  Effect.withSpan(spanName, {
713
749
  attributes: {
714
750
  "http.method": method,
@@ -717,8 +753,8 @@ export const makeAPI = <Creds>(config: ClientConfig<Creds>) => {
717
753
  }),
718
754
  );
719
755
  return distilledDebugEnv
720
- ? Effect.provideService(withRetry, MinimumLogLevel, "Debug")
721
- : withRetry;
756
+ ? Effect.provideService(withSpan, MinimumLogLevel, "Debug")
757
+ : withSpan;
722
758
  };
723
759
 
724
760
  const Proto = {
package/src/errors.ts CHANGED
@@ -5,9 +5,27 @@
5
5
  * using the category system. This module provides base error types and utilities
6
6
  * that are used across all SDKs.
7
7
  */
8
+ import * as Duration from "effect/Duration";
8
9
  import * as Schema from "effect/Schema";
9
10
  import * as Category from "./category.ts";
10
11
 
12
+ // ============================================================================
13
+ // Shared Schema Helpers
14
+ // ============================================================================
15
+
16
+ /**
17
+ * Opaque schema for an Effect `Duration.Duration` value.
18
+ *
19
+ * Used on retryable errors to carry a server-provided wait hint (e.g. parsed
20
+ * from `Retry-After` or the IETF `Ratelimit` header). Core does not encode/decode
21
+ * this field over the wire — it is constructed at runtime by `matchError` from
22
+ * whatever the service actually returns (delay-seconds, HTTP-date, epoch, etc.)
23
+ * and consumed at runtime by the retry policy.
24
+ */
25
+ export const DurationSchema = Schema.declare<Duration.Duration>(
26
+ Duration.isDuration,
27
+ );
28
+
11
29
  // ============================================================================
12
30
  // Common HTTP Status Error Classes
13
31
  // ============================================================================
@@ -63,7 +81,10 @@ export class UnprocessableEntity extends Schema.TaggedErrorClass<UnprocessableEn
63
81
  */
64
82
  export class TooManyRequests extends Schema.TaggedErrorClass<TooManyRequests>()(
65
83
  "TooManyRequests",
66
- { message: Schema.String },
84
+ {
85
+ message: Schema.String,
86
+ retryAfter: Schema.optional(DurationSchema),
87
+ },
67
88
  ).pipe(
68
89
  Category.withThrottlingError,
69
90
  Category.withRetryable({ throttling: true }),
@@ -74,6 +95,7 @@ export class TooManyRequests extends Schema.TaggedErrorClass<TooManyRequests>()(
74
95
  */
75
96
  export class Locked extends Schema.TaggedErrorClass<Locked>()("Locked", {
76
97
  message: Schema.String,
98
+ retryAfter: Schema.optional(DurationSchema),
77
99
  }).pipe(Category.withLockedError, Category.withRetryable()) {}
78
100
 
79
101
  /**
@@ -81,7 +103,10 @@ export class Locked extends Schema.TaggedErrorClass<Locked>()("Locked", {
81
103
  */
82
104
  export class InternalServerError extends Schema.TaggedErrorClass<InternalServerError>()(
83
105
  "InternalServerError",
84
- { message: Schema.String },
106
+ {
107
+ message: Schema.String,
108
+ retryAfter: Schema.optional(DurationSchema),
109
+ },
85
110
  ).pipe(Category.withServerError, Category.withRetryable()) {}
86
111
 
87
112
  /**
@@ -89,7 +114,10 @@ export class InternalServerError extends Schema.TaggedErrorClass<InternalServerE
89
114
  */
90
115
  export class BadGateway extends Schema.TaggedErrorClass<BadGateway>()(
91
116
  "BadGateway",
92
- { message: Schema.String },
117
+ {
118
+ message: Schema.String,
119
+ retryAfter: Schema.optional(DurationSchema),
120
+ },
93
121
  ).pipe(Category.withServerError, Category.withRetryable()) {}
94
122
 
95
123
  /**
@@ -97,7 +125,10 @@ export class BadGateway extends Schema.TaggedErrorClass<BadGateway>()(
97
125
  */
98
126
  export class ServiceUnavailable extends Schema.TaggedErrorClass<ServiceUnavailable>()(
99
127
  "ServiceUnavailable",
100
- { message: Schema.String },
128
+ {
129
+ message: Schema.String,
130
+ retryAfter: Schema.optional(DurationSchema),
131
+ },
101
132
  ).pipe(Category.withServerError, Category.withRetryable()) {}
102
133
 
103
134
  /**
@@ -105,7 +136,10 @@ export class ServiceUnavailable extends Schema.TaggedErrorClass<ServiceUnavailab
105
136
  */
106
137
  export class GatewayTimeout extends Schema.TaggedErrorClass<GatewayTimeout>()(
107
138
  "GatewayTimeout",
108
- { message: Schema.String },
139
+ {
140
+ message: Schema.String,
141
+ retryAfter: Schema.optional(DurationSchema),
142
+ },
109
143
  ).pipe(Category.withServerError, Category.withRetryable()) {}
110
144
 
111
145
  /**
@@ -144,6 +178,18 @@ export const HTTP_STATUS_MAP = {
144
178
  */
145
179
  export const DEFAULT_ERROR_STATUSES = new Set([401, 429, 500, 502, 503, 504]);
146
180
 
181
+ /**
182
+ * HTTP status codes whose corresponding error class in {@link HTTP_STATUS_MAP}
183
+ * declares a `retryAfter` field. The retry policy honors `error.retryAfter`
184
+ * with precedence over the default backoff for these.
185
+ *
186
+ * `matchError` implementations should only pass `retryAfter` into the
187
+ * constructor when the status is in this set; passing it on non-retryable
188
+ * classes (BadRequest/Unauthorized/etc.) would silently retain it as a
189
+ * stale field on the instance and pollute serialized output.
190
+ */
191
+ export const RETRYABLE_HTTP_STATUSES = new Set([423, 429, 500, 502, 503, 504]);
192
+
147
193
  /**
148
194
  * All common API error classes.
149
195
  */
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Server-provided retry hint parsing.
3
+ *
4
+ * Standardizes parsing of:
5
+ * - `Retry-After` (RFC 7231 §7.1.3): `delay-seconds` (integer) or `HTTP-date`.
6
+ * - `RateLimit` (IETF draft-ietf-httpapi-ratelimit-headers): structured
7
+ * dictionary `r=<remaining>, t=<seconds-until-reset>`.
8
+ *
9
+ * Each SDK's `matchError` calls these helpers and attaches the resulting
10
+ * `Duration` to retryable errors via the optional `retryAfter` field, where
11
+ * the retry policy will honor it with precedence over the default backoff.
12
+ *
13
+ * Services that use bespoke headers or body fields are expected to do their
14
+ * own parsing in the SDK's `matchError`, optionally falling back to these
15
+ * helpers for the standard cases.
16
+ */
17
+ import * as Duration from "effect/Duration";
18
+ import { RETRYABLE_HTTP_STATUSES } from "./errors.ts";
19
+
20
+ /**
21
+ * Header bag — case-insensitive lookup is the caller's responsibility.
22
+ *
23
+ * The helpers in this module read lowercase keys (`retry-after`, `ratelimit`)
24
+ * because most HTTP clients normalize headers to lowercase. If your client
25
+ * preserves case, normalize before passing in.
26
+ */
27
+ export type Headers = Record<string, string | undefined> | undefined;
28
+
29
+ /**
30
+ * Parse the standard `Retry-After` HTTP header per RFC 7231 §7.1.3.
31
+ *
32
+ * Accepts either a non-negative integer number of seconds (the common case)
33
+ * or an HTTP-date. HTTP-dates in the past produce a zero duration.
34
+ *
35
+ * Returns `undefined` when the header is missing or unparseable.
36
+ */
37
+ export const parseRetryAfter = (
38
+ headers: Headers,
39
+ ): Duration.Duration | undefined => {
40
+ const raw = headers?.["retry-after"];
41
+ if (!raw) return undefined;
42
+ const trimmed = raw.trim();
43
+ if (trimmed.length === 0) return undefined;
44
+
45
+ // delay-seconds: 1*DIGIT (non-negative decimal integer per RFC).
46
+ if (/^\d+$/.test(trimmed)) {
47
+ const seconds = Number(trimmed);
48
+ if (Number.isFinite(seconds) && seconds >= 0) {
49
+ return Duration.seconds(seconds);
50
+ }
51
+ }
52
+
53
+ // Reject anything that *looks* numeric but isn't a valid delay-seconds.
54
+ // Without this, `Date.parse("-5")` happens to return 0 on some engines and
55
+ // would silently yield a zero-duration instead of the expected undefined.
56
+ if (/^[-+]?\d+(?:\.\d+)?$/.test(trimmed)) return undefined;
57
+
58
+ // HTTP-date: try Date.parse, which handles RFC 1123 / RFC 850 / asctime.
59
+ const ts = Date.parse(trimmed);
60
+ if (!Number.isNaN(ts)) {
61
+ const ms = Math.max(0, ts - Date.now());
62
+ return Duration.millis(ms);
63
+ }
64
+
65
+ return undefined;
66
+ };
67
+
68
+ /**
69
+ * Parse the IETF `RateLimit` structured header.
70
+ *
71
+ * Examples:
72
+ * `RateLimit: limit=100, remaining=0, reset=30` (older draft)
73
+ * `RateLimit: "default";r=0;t=30` (newer draft)
74
+ * `RateLimit: r=0, t=30` (compact form)
75
+ *
76
+ * Returns the `t` (seconds until reset) value as a Duration, but only when
77
+ * `r` (remaining quota) is present and equals `0` — i.e., we're actually
78
+ * rate-limited right now. Returns `undefined` otherwise.
79
+ */
80
+ export const parseRatelimit = (
81
+ headers: Headers,
82
+ ): Duration.Duration | undefined => {
83
+ const raw = headers?.["ratelimit"];
84
+ if (!raw) return undefined;
85
+
86
+ let remaining: number | undefined;
87
+ let reset: number | undefined;
88
+
89
+ // Tokenize on commas and semicolons; tolerate `key=value` and `key =value`.
90
+ for (const piece of raw.split(/[,;]/)) {
91
+ const eq = piece.indexOf("=");
92
+ if (eq < 0) continue;
93
+ const key = piece.slice(0, eq).trim().toLowerCase();
94
+ const value = piece.slice(eq + 1).trim();
95
+ const num = Number(value);
96
+ if (!Number.isFinite(num)) continue;
97
+
98
+ if (key === "r" || key === "remaining") remaining = num;
99
+ else if (key === "t" || key === "reset") reset = num;
100
+ }
101
+
102
+ if (remaining !== undefined && remaining > 0) return undefined;
103
+ if (reset === undefined || reset < 0) return undefined;
104
+ return Duration.seconds(reset);
105
+ };
106
+
107
+ /**
108
+ * Convenience: try `Retry-After` first, then `RateLimit`. Returns the first
109
+ * successful parse, or `undefined` if neither header yields a usable value.
110
+ */
111
+ export const parseServerRetryHint = (
112
+ headers: Headers,
113
+ ): Duration.Duration | undefined =>
114
+ parseRetryAfter(headers) ?? parseRatelimit(headers);
115
+
116
+ /**
117
+ * Status-gated variant of {@link parseServerRetryHint}. Returns `undefined`
118
+ * unless `status` is one whose error class actually declares a `retryAfter`
119
+ * field (see `RETRYABLE_HTTP_STATUSES`). Use this in `matchError` when you
120
+ * dispatch off a generic `HTTP_STATUS_MAP` lookup that includes both
121
+ * retryable (429/5xx/423) and non-retryable (4xx) classes — it prevents
122
+ * stale `retryAfter` properties from being attached to errors like
123
+ * `BadRequest` or `NotFound` (which would otherwise leak into JSON output).
124
+ */
125
+ export const parseRetryAfterForStatus = (
126
+ status: number,
127
+ headers: Headers,
128
+ ): Duration.Duration | undefined => {
129
+ if (!RETRYABLE_HTTP_STATUSES.has(status)) return undefined;
130
+ return parseServerRetryHint(headers);
131
+ };
package/src/retry.ts CHANGED
@@ -2,24 +2,16 @@
2
2
  * Retry Policy System
3
3
  *
4
4
  * Provides configurable retry policies for API operations.
5
- * Each SDK creates its own Retry service tag but uses these shared utilities
6
- * for building retry schedules and policies.
7
- *
8
- * @example
9
- * ```ts
10
- * import * as Retry from "@distilled.cloud/core/retry";
11
- *
12
- * // Use the default retry policy
13
- * myEffect.pipe(Retry.policy(myRetryService, Retry.makeDefault()))
14
- *
15
- * // Disable retries
16
- * myEffect.pipe(Retry.none(myRetryService))
17
- * ```
5
+ * Each SDK creates its own `Retry` Context.Service tag (via
6
+ * `makeRetryService`) and threads it into `makeAPI({ retry: <SDK>.Retry })`
7
+ * so that callers can install a blanket retry policy at the layer level
8
+ * for that SDK without wrapping every call with `Effect.retry`.
18
9
  */
19
10
  import * as Duration from "effect/Duration";
20
11
  import * as Effect from "effect/Effect";
21
12
  import { pipe } from "effect/Function";
22
13
  import * as Layer from "effect/Layer";
14
+ import * as Option from "effect/Option";
23
15
  import * as Ref from "effect/Ref";
24
16
  import * as Schedule from "effect/Schedule";
25
17
  import * as Context from "effect/Context";
@@ -60,36 +52,19 @@ export type Policy = Options | Factory;
60
52
 
61
53
  /**
62
54
  * Create a typed Retry service class for an SDK.
63
- * Each SDK should create its own Retry service using this factory.
55
+ * Each SDK creates its own Retry service using this factory; the SDK's
56
+ * client wraps `makeAPI` with `retry: <SDK>.Retry` so the policy applies
57
+ * to every API call below it.
64
58
  *
65
59
  * @example
66
60
  * ```ts
67
- * // In planetscale-sdk/src/retry.ts
61
+ * // packages/<sdk>/src/retry.ts
68
62
  * export class Retry extends makeRetryService("PlanetScaleRetry") {}
69
63
  * ```
70
64
  */
71
65
  export const makeRetryService = (name: string) =>
72
66
  Context.Service<any, Policy>()(name);
73
67
 
74
- /**
75
- * Provides a custom retry policy for API calls.
76
- */
77
- export const policy =
78
- (Service: any, optionsOrFactory: Policy) =>
79
- <A, E, R>(effect: Effect.Effect<A, E, R>) =>
80
- Effect.provide(effect, Layer.succeed(Service, optionsOrFactory) as any);
81
-
82
- /**
83
- * Disables all automatic retries.
84
- */
85
- export const none =
86
- (Service: any) =>
87
- <A, E, R>(effect: Effect.Effect<A, E, R>) =>
88
- Effect.provide(
89
- effect,
90
- Layer.succeed(Service, { while: () => false }) as any,
91
- );
92
-
93
68
  // ============================================================================
94
69
  // Retry Schedule Utilities
95
70
  // ============================================================================
@@ -112,6 +87,110 @@ export const capped = (max: Duration.Duration) =>
112
87
  ),
113
88
  );
114
89
 
90
+ // ============================================================================
91
+ // Server Hint Helpers
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Default cap (milliseconds) on how long we'll honor a server-provided
96
+ * `Retry-After` / `RateLimit` hint. A misbehaving server can otherwise park
97
+ * us forever.
98
+ */
99
+ export const DEFAULT_SERVER_RETRY_HINT_CAP_MS = 60_000;
100
+
101
+ const ENV_SERVER_RETRY_HINT_CAP_MS = "DISTILLED_SERVER_RETRY_HINT_CAP_MS";
102
+
103
+ /**
104
+ * Optional override for the server-hint cap, in milliseconds. When provided
105
+ * via `Layer.succeed(ServerRetryHintCapMs, n)`, that value wins over the
106
+ * environment.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * Effect.retry(op, policy).pipe(
111
+ * Effect.provide(serverRetryHintCapLayer(120_000)),
112
+ * )
113
+ * ```
114
+ */
115
+ export const ServerRetryHintCapMs = Context.Service<number>(
116
+ "@distilled.cloud/core/ServerRetryHintCapMs",
117
+ );
118
+
119
+ /**
120
+ * Layer that pins the server `retryAfter` cap to a fixed value (milliseconds).
121
+ */
122
+ export const serverRetryHintCapLayer = (capMs: number) =>
123
+ Layer.succeed(ServerRetryHintCapMs, capMs);
124
+
125
+ /**
126
+ * Effective cap when neither {@link ServerRetryHintCapMs} nor
127
+ * `DISTILLED_SERVER_RETRY_HINT_CAP_MS` is set: {@link DEFAULT_SERVER_RETRY_HINT_CAP_MS}.
128
+ *
129
+ * Reads `process.env.DISTILLED_SERVER_RETRY_HINT_CAP_MS` when present (must be
130
+ * a non-negative finite integer; invalid values are ignored). Undefined
131
+ * `process` (some edge runtimes) falls back to the default.
132
+ */
133
+ export const readServerRetryHintCapMsFromEnv = (): number => {
134
+ if (typeof process === "undefined" || process.env === undefined) {
135
+ return DEFAULT_SERVER_RETRY_HINT_CAP_MS;
136
+ }
137
+ const raw = process.env[ENV_SERVER_RETRY_HINT_CAP_MS];
138
+ if (raw === undefined || raw === "") return DEFAULT_SERVER_RETRY_HINT_CAP_MS;
139
+ const n = Number(raw);
140
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_SERVER_RETRY_HINT_CAP_MS;
141
+ return Math.trunc(n);
142
+ };
143
+
144
+ const resolveServerRetryHintCapMs = (): Effect.Effect<number, never, never> =>
145
+ Effect.gen(function* () {
146
+ const fromLayer = yield* Effect.serviceOption(ServerRetryHintCapMs);
147
+ return Option.match(fromLayer, {
148
+ onNone: () => readServerRetryHintCapMsFromEnv(),
149
+ onSome: (n) =>
150
+ Number.isFinite(n) && n >= 0
151
+ ? Math.trunc(n)
152
+ : readServerRetryHintCapMsFromEnv(),
153
+ });
154
+ });
155
+
156
+ /**
157
+ * Extract a server-provided retry hint (in milliseconds) from an error's
158
+ * optional `retryAfter` field, capped by the effective server-hint cap
159
+ * (see {@link ServerRetryHintCapMs} and {@link readServerRetryHintCapMsFromEnv}).
160
+ *
161
+ * Returns `undefined` when the error doesn't carry a hint.
162
+ */
163
+ const serverHintMillis = (
164
+ error: unknown,
165
+ capMs: number,
166
+ ): number | undefined => {
167
+ const hint = (error as { retryAfter?: unknown } | null | undefined)
168
+ ?.retryAfter;
169
+ if (!Duration.isDuration(hint)) return undefined;
170
+ return Math.min(Duration.toMillis(hint), capMs);
171
+ };
172
+
173
+ /**
174
+ * Schedule modifier that honors `error.retryAfter` (set by the SDK's
175
+ * `matchError` from `Retry-After` / `RateLimit` headers) with precedence
176
+ * over the input delay. Falls back to the original delay when no hint is
177
+ * present.
178
+ */
179
+ const honorServerHint = (
180
+ lastError: Ref.Ref<unknown>,
181
+ baseline?: (duration: Duration.Duration, error: unknown) => Duration.Duration,
182
+ ) =>
183
+ Schedule.modifyDelay(
184
+ Effect.fnUntraced(function* (duration: Duration.Duration) {
185
+ const capMs = yield* resolveServerRetryHintCapMs();
186
+ const error = yield* Ref.get(lastError);
187
+ const hint = serverHintMillis(error, capMs);
188
+ if (hint !== undefined) return hint;
189
+ const adjusted = baseline ? baseline(duration, error) : duration;
190
+ return Duration.toMillis(adjusted);
191
+ }),
192
+ );
193
+
115
194
  // ============================================================================
116
195
  // Default Retry Policies
117
196
  // ============================================================================
@@ -121,7 +200,10 @@ export const capped = (max: Duration.Duration) =>
121
200
  *
122
201
  * This policy:
123
202
  * - Retries transient errors (throttling, server, network, locked errors)
124
- * - Uses exponential backoff starting at 100ms with a factor of 2
203
+ * - Honors `error.retryAfter` (server-provided hint) with precedence, capped
204
+ * by {@link DEFAULT_SERVER_RETRY_HINT_CAP_MS} by default; override with
205
+ * `DISTILLED_SERVER_RETRY_HINT_CAP_MS` or {@link ServerRetryHintCapMs}
206
+ * - Otherwise uses exponential backoff starting at 100ms with a factor of 2
125
207
  * - Ensures at least 500ms delay for throttling errors
126
208
  * - Limits to 5 retry attempts
127
209
  * - Applies jitter to avoid thundering herd
@@ -130,24 +212,37 @@ export const makeDefault: Factory = (lastError) => ({
130
212
  while: (error) => isTransientError(error),
131
213
  schedule: pipe(
132
214
  Schedule.exponential(100, 2),
133
- Schedule.modifyDelay(
134
- Effect.fnUntraced(function* (duration) {
135
- const error = yield* Ref.get(lastError);
136
- if (isThrottling(error)) {
137
- if (Duration.toMillis(duration) < 500) {
138
- return Duration.toMillis(Duration.millis(500));
139
- }
140
- }
141
- return Duration.toMillis(duration);
142
- }),
143
- ),
215
+ honorServerHint(lastError, (duration, error) => {
216
+ if (isThrottling(error) && Duration.toMillis(duration) < 500) {
217
+ return Duration.millis(500);
218
+ }
219
+ return duration;
220
+ }),
144
221
  Schedule.both(Schedule.recurs(5)),
145
222
  jittered,
146
223
  ),
147
224
  });
148
225
 
226
+ /**
227
+ * Factory for the throttling-only retry policy. Honors server hints when
228
+ * present so callers using `<SDK>.Retry.throttling` get correct backoff.
229
+ */
230
+ export const throttlingFactory: Factory = (lastError) => ({
231
+ while: (error) => isThrottling(error),
232
+ schedule: pipe(
233
+ Schedule.exponential(1000, 2),
234
+ honorServerHint(lastError),
235
+ capped(Duration.seconds(5)),
236
+ jittered,
237
+ ),
238
+ });
239
+
149
240
  /**
150
241
  * Retry options that retries all throttling errors indefinitely.
242
+ *
243
+ * Note: prefer {@link throttlingFactory} when you want server-hint honoring.
244
+ * This static `Options` form does not have access to `lastError` and so
245
+ * cannot read `error.retryAfter`.
151
246
  */
152
247
  export const throttlingOptions: Options = {
153
248
  while: (error) => isThrottling(error),
@@ -158,6 +253,20 @@ export const throttlingOptions: Options = {
158
253
  ),
159
254
  };
160
255
 
256
+ /**
257
+ * Factory for the transient retry policy. Honors server hints when present
258
+ * so callers using `<SDK>.Retry.transient` get correct backoff.
259
+ */
260
+ export const transientFactory: Factory = (lastError) => ({
261
+ while: isTransientError,
262
+ schedule: pipe(
263
+ Schedule.exponential(1000, 2),
264
+ honorServerHint(lastError),
265
+ capped(Duration.seconds(5)),
266
+ jittered,
267
+ ),
268
+ });
269
+
161
270
  /**
162
271
  * Retry options that retries all transient errors indefinitely.
163
272
  *
@@ -166,6 +275,8 @@ export const throttlingOptions: Options = {
166
275
  * 2. Server errors
167
276
  * 3. Network errors
168
277
  * 4. Locked errors (423)
278
+ *
279
+ * Note: prefer {@link transientFactory} when you want server-hint honoring.
169
280
  */
170
281
  export const transientOptions: Options = {
171
282
  while: isTransientError,