@distilled.cloud/core 0.16.1 → 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/lib/client.d.ts +52 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +29 -26
- package/lib/client.js.map +1 -1
- package/lib/errors.d.ts +28 -0
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +46 -5
- package/lib/errors.js.map +1 -1
- package/lib/retry-after.d.ts +63 -0
- package/lib/retry-after.d.ts.map +1 -0
- package/lib/retry-after.js +112 -0
- package/lib/retry-after.js.map +1 -0
- package/lib/retry.d.ts +61 -25
- package/lib/retry.d.ts.map +1 -1
- package/lib/retry.js +125 -32
- package/lib/retry.js.map +1 -1
- package/package.json +6 -1
- package/src/client.ts +65 -29
- package/src/errors.ts +51 -5
- package/src/retry-after.ts +131 -0
- package/src/retry.ts +157 -46
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
|
|
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
|
|
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
|
|
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
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
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 =
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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(
|
|
721
|
-
:
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
* //
|
|
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
|
-
* -
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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,
|