@vereda/http 1.0.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/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/adapters/zod.d.ts +14 -0
- package/dist/adapters/zod.d.ts.map +1 -0
- package/dist/adapters/zod.js +14 -0
- package/dist/adapters/zod.js.map +1 -0
- package/dist/core/backoff.d.ts +9 -0
- package/dist/core/backoff.d.ts.map +1 -0
- package/dist/core/backoff.js +23 -0
- package/dist/core/backoff.js.map +1 -0
- package/dist/core/client.d.ts +98 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +781 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/errors.d.ts +87 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +140 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/listeners.d.ts +14 -0
- package/dist/core/listeners.d.ts.map +1 -0
- package/dist/core/listeners.js +27 -0
- package/dist/core/listeners.js.map +1 -0
- package/dist/core/metrics.d.ts +33 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +24 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/nanoid.d.ts +2 -0
- package/dist/core/nanoid.d.ts.map +1 -0
- package/dist/core/nanoid.js +11 -0
- package/dist/core/nanoid.js.map +1 -0
- package/dist/core/redact.d.ts +12 -0
- package/dist/core/redact.d.ts.map +1 -0
- package/dist/core/redact.js +42 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/types.d.ts +261 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +41 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validate.d.ts +19 -0
- package/dist/core/validate.d.ts.map +1 -0
- package/dist/core/validate.js +135 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/middleware/index.d.ts +26 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +55 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/queue/bulkhead.d.ts +63 -0
- package/dist/queue/bulkhead.d.ts.map +1 -0
- package/dist/queue/bulkhead.js +192 -0
- package/dist/queue/bulkhead.js.map +1 -0
- package/dist/queue/circuit-breaker.d.ts +81 -0
- package/dist/queue/circuit-breaker.d.ts.map +1 -0
- package/dist/queue/circuit-breaker.js +283 -0
- package/dist/queue/circuit-breaker.js.map +1 -0
- package/dist/queue/executor.d.ts +67 -0
- package/dist/queue/executor.d.ts.map +1 -0
- package/dist/queue/executor.js +273 -0
- package/dist/queue/executor.js.map +1 -0
- package/dist/queue/policy.d.ts +26 -0
- package/dist/queue/policy.d.ts.map +1 -0
- package/dist/queue/policy.js +37 -0
- package/dist/queue/policy.js.map +1 -0
- package/dist/queue/retry.d.ts +58 -0
- package/dist/queue/retry.d.ts.map +1 -0
- package/dist/queue/retry.js +259 -0
- package/dist/queue/retry.js.map +1 -0
- package/dist/queue/semaphore.d.ts +32 -0
- package/dist/queue/semaphore.d.ts.map +1 -0
- package/dist/queue/semaphore.js +83 -0
- package/dist/queue/semaphore.js.map +1 -0
- package/dist/ticket/ticket.d.ts +77 -0
- package/dist/ticket/ticket.d.ts.map +1 -0
- package/dist/ticket/ticket.js +186 -0
- package/dist/ticket/ticket.js.map +1 -0
- package/package.json +85 -0
- package/src/adapters/zod.ts +16 -0
- package/src/core/backoff.ts +26 -0
- package/src/core/client.ts +1048 -0
- package/src/core/errors.ts +194 -0
- package/src/core/index.ts +56 -0
- package/src/core/listeners.ts +28 -0
- package/src/core/metrics.ts +42 -0
- package/src/core/nanoid.ts +11 -0
- package/src/core/redact.ts +46 -0
- package/src/core/types.ts +306 -0
- package/src/core/validate.ts +163 -0
- package/src/middleware/index.ts +63 -0
- package/src/queue/bulkhead.ts +243 -0
- package/src/queue/circuit-breaker.ts +373 -0
- package/src/queue/executor.ts +355 -0
- package/src/queue/policy.ts +49 -0
- package/src/queue/retry.ts +380 -0
- package/src/queue/semaphore.ts +91 -0
- package/src/ticket/ticket.ts +246 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export type AppError =
|
|
2
|
+
| NetworkError
|
|
3
|
+
| HttpError
|
|
4
|
+
| RetryableStatusError
|
|
5
|
+
| TimeoutError
|
|
6
|
+
| DeadlineExceededError
|
|
7
|
+
| ValidationError
|
|
8
|
+
| CancelledError
|
|
9
|
+
| QueueFullError
|
|
10
|
+
| ConfigurationError
|
|
11
|
+
| MaxRetriesExceededError
|
|
12
|
+
| CircuitOpenError;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Base class for failures that prevent a request from
|
|
16
|
+
* producing a successful result.
|
|
17
|
+
*/
|
|
18
|
+
export class RequestError extends Error {
|
|
19
|
+
public readonly kind: string;
|
|
20
|
+
public cause?: unknown;
|
|
21
|
+
|
|
22
|
+
constructor(kind: string, message: string, cause?: unknown) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = this.constructor.name;
|
|
25
|
+
this.kind = kind;
|
|
26
|
+
if (cause !== undefined) {
|
|
27
|
+
this.cause = cause;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class NetworkError extends RequestError {
|
|
33
|
+
declare readonly kind: "network";
|
|
34
|
+
|
|
35
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
36
|
+
super("network", message, options?.cause);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class HttpError extends RequestError {
|
|
41
|
+
declare readonly kind: "http";
|
|
42
|
+
public readonly statusCode: number;
|
|
43
|
+
public readonly response: Response;
|
|
44
|
+
|
|
45
|
+
constructor(message: string, statusCode: number, response: Response) {
|
|
46
|
+
super("http", message);
|
|
47
|
+
this.statusCode = statusCode;
|
|
48
|
+
this.response = response;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class RetryableStatusError extends RequestError {
|
|
53
|
+
declare readonly kind: "retryable_status";
|
|
54
|
+
public readonly statusCode: number;
|
|
55
|
+
public readonly response: Response;
|
|
56
|
+
public readonly retryAfterMs?: number;
|
|
57
|
+
|
|
58
|
+
constructor(message: string, statusCode: number, response: Response, retryAfterMs?: number) {
|
|
59
|
+
super("retryable_status", message);
|
|
60
|
+
this.statusCode = statusCode;
|
|
61
|
+
this.response = response;
|
|
62
|
+
if (retryAfterMs !== undefined) {
|
|
63
|
+
this.retryAfterMs = retryAfterMs;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Sentinel `timeoutMs` value reported on `TimeoutError` when no per-attempt
|
|
69
|
+
* timeout (`TimeoutConfig.attemptMs`) was configured. Callers that construct
|
|
70
|
+
* a `TimeoutError` for an unconfigured timeout should pass this instead of
|
|
71
|
+
* a bare `0` so there is one place this convention is spelled out. */
|
|
72
|
+
export const NO_TIMEOUT_CONFIGURED = 0;
|
|
73
|
+
|
|
74
|
+
export class TimeoutError extends RequestError {
|
|
75
|
+
declare readonly kind: "timeout";
|
|
76
|
+
public readonly timeoutMs: number;
|
|
77
|
+
public readonly url: string;
|
|
78
|
+
|
|
79
|
+
constructor(url: string, timeoutMs: number) {
|
|
80
|
+
const message =
|
|
81
|
+
timeoutMs > NO_TIMEOUT_CONFIGURED
|
|
82
|
+
? `Request to ${url} timed out after ${timeoutMs}ms`
|
|
83
|
+
: `Request to ${url} timed out (no timeout configured)`;
|
|
84
|
+
super("timeout", message);
|
|
85
|
+
this.timeoutMs = timeoutMs;
|
|
86
|
+
this.url = url;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class DeadlineExceededError extends RequestError {
|
|
91
|
+
declare readonly kind: "deadline";
|
|
92
|
+
public readonly url: string;
|
|
93
|
+
public readonly totalMs: number;
|
|
94
|
+
|
|
95
|
+
constructor(url: string, totalMs: number) {
|
|
96
|
+
super("deadline", `Request to ${url} exceeded total deadline of ${totalMs}ms`);
|
|
97
|
+
this.url = url;
|
|
98
|
+
this.totalMs = totalMs;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class ValidationError extends RequestError {
|
|
103
|
+
declare readonly kind: "validation";
|
|
104
|
+
public readonly issues: unknown[];
|
|
105
|
+
|
|
106
|
+
constructor(message: string, issues: unknown[], cause?: unknown) {
|
|
107
|
+
super("validation", message, cause);
|
|
108
|
+
this.issues = issues;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class CancelledError extends RequestError {
|
|
113
|
+
declare readonly kind: "cancelled";
|
|
114
|
+
|
|
115
|
+
constructor(message = "Request was cancelled") {
|
|
116
|
+
super("cancelled", message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class QueueFullError extends RequestError {
|
|
121
|
+
declare readonly kind: "queue_full";
|
|
122
|
+
public readonly partition: string;
|
|
123
|
+
public readonly queueSize: number;
|
|
124
|
+
public readonly maxQueueSize: number;
|
|
125
|
+
|
|
126
|
+
constructor(partition: string, queueSize: number, maxQueueSize: number) {
|
|
127
|
+
super("queue_full", `Queue for partition '${partition}' is full (${queueSize}/${maxQueueSize})`);
|
|
128
|
+
this.partition = partition;
|
|
129
|
+
this.queueSize = queueSize;
|
|
130
|
+
this.maxQueueSize = maxQueueSize;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class ConfigurationError extends RequestError {
|
|
135
|
+
declare readonly kind: "configuration";
|
|
136
|
+
public readonly key: string;
|
|
137
|
+
|
|
138
|
+
constructor(key: string, options?: { cause?: unknown }) {
|
|
139
|
+
super("configuration", `Invalid configuration: ${key}`, options?.cause);
|
|
140
|
+
this.key = key;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class MaxRetriesExceededError extends RequestError {
|
|
145
|
+
declare readonly kind: "max_retries";
|
|
146
|
+
public readonly attempts: number;
|
|
147
|
+
public readonly lastError: AppError;
|
|
148
|
+
|
|
149
|
+
constructor(attempts: number, lastError: AppError) {
|
|
150
|
+
super(
|
|
151
|
+
"max_retries",
|
|
152
|
+
`Request failed after ${attempts} attempt${attempts === 1 ? "" : "s"}: ${lastError.message}`,
|
|
153
|
+
lastError,
|
|
154
|
+
);
|
|
155
|
+
this.attempts = attempts;
|
|
156
|
+
this.lastError = lastError;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class CircuitOpenError extends RequestError {
|
|
161
|
+
declare readonly kind: "circuit_open";
|
|
162
|
+
public readonly partition: string;
|
|
163
|
+
|
|
164
|
+
constructor(partition: string) {
|
|
165
|
+
super("circuit_open", `Circuit breaker is open for partition '${partition}'`);
|
|
166
|
+
this.partition = partition;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Narrows an unknown throw to one of this library's own error classes, so
|
|
172
|
+
* internal catch sites can preserve a pre-typed failure (e.g. a
|
|
173
|
+
* `QueueFullError` from the bulkhead) and wrap anything else.
|
|
174
|
+
*/
|
|
175
|
+
export function isAppError(err: unknown): err is AppError {
|
|
176
|
+
// instanceof, not a `kind` lookup: a subclass of one of these still carries
|
|
177
|
+
// its fields (e.g. statusCode), whereas a bare RequestError with a matching
|
|
178
|
+
// kind string would not.
|
|
179
|
+
return APP_ERROR_CLASSES.some((cls) => err instanceof cls);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const APP_ERROR_CLASSES = [
|
|
183
|
+
NetworkError,
|
|
184
|
+
HttpError,
|
|
185
|
+
RetryableStatusError,
|
|
186
|
+
TimeoutError,
|
|
187
|
+
DeadlineExceededError,
|
|
188
|
+
ValidationError,
|
|
189
|
+
CancelledError,
|
|
190
|
+
QueueFullError,
|
|
191
|
+
ConfigurationError,
|
|
192
|
+
MaxRetriesExceededError,
|
|
193
|
+
CircuitOpenError,
|
|
194
|
+
] as const;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type { RetryPolicy, RetryPolicyContext } from "../queue/policy.ts";
|
|
2
|
+
export { defaultRetryPolicy } from "../queue/policy.ts";
|
|
3
|
+
export type { TicketStatus, TicketUpdate } from "../ticket/ticket.ts";
|
|
4
|
+
export { Ticket } from "../ticket/ticket.ts";
|
|
5
|
+
export { DEFAULT_BASE_DELAY_MS, DEFAULT_JITTER, DEFAULT_MAX_DELAY_MS } from "./backoff.ts";
|
|
6
|
+
export { HttpClient, json } from "./client.ts";
|
|
7
|
+
export type { AppError } from "./errors.ts";
|
|
8
|
+
export {
|
|
9
|
+
CancelledError,
|
|
10
|
+
CircuitOpenError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
DeadlineExceededError,
|
|
13
|
+
HttpError,
|
|
14
|
+
MaxRetriesExceededError,
|
|
15
|
+
NetworkError,
|
|
16
|
+
NO_TIMEOUT_CONFIGURED,
|
|
17
|
+
QueueFullError,
|
|
18
|
+
RequestError,
|
|
19
|
+
RetryableStatusError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
} from "./errors.ts";
|
|
23
|
+
export type { MetricsSink, MetricTags } from "./metrics.ts";
|
|
24
|
+
export { METRICS } from "./metrics.ts";
|
|
25
|
+
export { redactUrl } from "./redact.ts";
|
|
26
|
+
export type {
|
|
27
|
+
BackoffFn,
|
|
28
|
+
BackoffOptions,
|
|
29
|
+
CircuitBreakerConfig,
|
|
30
|
+
ClientConfig,
|
|
31
|
+
ClientTimeoutConfig,
|
|
32
|
+
CloseOptions,
|
|
33
|
+
LifecycleEventMap,
|
|
34
|
+
Logger,
|
|
35
|
+
ParsedRequestOptions,
|
|
36
|
+
ParseFn,
|
|
37
|
+
PartitionConfig,
|
|
38
|
+
RequestOptions,
|
|
39
|
+
Result,
|
|
40
|
+
RetryConfig,
|
|
41
|
+
TimeoutConfig,
|
|
42
|
+
UnparsedRequestOptions,
|
|
43
|
+
} from "./types.ts";
|
|
44
|
+
export {
|
|
45
|
+
DEFAULT_CONCURRENCY,
|
|
46
|
+
DEFAULT_FAILURE_THRESHOLD,
|
|
47
|
+
DEFAULT_GLOBAL_CONCURRENCY,
|
|
48
|
+
DEFAULT_GLOBAL_QUEUE_SIZE,
|
|
49
|
+
DEFAULT_HALF_OPEN_MAX_ATTEMPTS,
|
|
50
|
+
DEFAULT_MAX_QUEUE_SIZE,
|
|
51
|
+
DEFAULT_MAX_RETRIES,
|
|
52
|
+
DEFAULT_RESET_TIMEOUT_MS,
|
|
53
|
+
DEFAULT_RETRY_ON_STATUS,
|
|
54
|
+
isBoundedMs,
|
|
55
|
+
} from "./types.ts";
|
|
56
|
+
export { validateConfig } from "./validate.ts";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
/** Surface an error thrown by user code (an event listener or a metrics sink)
|
|
4
|
+
* without letting it unwind through vereda's internals. It is rethrown on a
|
|
5
|
+
* fresh microtask, so it reaches `process.on("uncaughtException")` exactly
|
|
6
|
+
* like a throwing listener on any other emitter would — the same contract as
|
|
7
|
+
* `node:diagnostics_channel` subscribers. Swallowing it would hide the bug;
|
|
8
|
+
* letting it propagate synchronously would abort whatever state transition
|
|
9
|
+
* was in progress (a ticket that never resolves, B2; a success rewritten
|
|
10
|
+
* as a failure, B3). */
|
|
11
|
+
export function reportCallbackError(err: unknown): void {
|
|
12
|
+
queueMicrotask(() => {
|
|
13
|
+
throw err;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `emitter.emit()` with per-listener isolation: every listener runs even if
|
|
18
|
+
* an earlier one throws, and no throw escapes to the caller. */
|
|
19
|
+
export function emitIsolated(emitter: EventEmitter, event: string, ...args: unknown[]): void {
|
|
20
|
+
// rawListeners, not listeners: calling a `once` wrapper also unregisters it.
|
|
21
|
+
for (const listener of emitter.rawListeners(event)) {
|
|
22
|
+
try {
|
|
23
|
+
listener(...args);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
reportCallbackError(err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Metrics sink — pluggable observability (O2)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
/** Tags attached to every metric emission. All values are strings so that
|
|
6
|
+
* callers can use whatever cardinality they need. */
|
|
7
|
+
export type MetricTags = Record<string, string>;
|
|
8
|
+
|
|
9
|
+
/** Pluggable sink for vereda metrics. Implement this interface to wire
|
|
10
|
+
* vereda into OpenTelemetry, Prometheus, Datadog, or any other metrics
|
|
11
|
+
* backend. The client calls these methods at well-defined lifecycle points;
|
|
12
|
+
* all calls are synchronous and non-blocking. */
|
|
13
|
+
export interface MetricsSink {
|
|
14
|
+
/** Monotonically increasing counter (e.g. total requests, total retries). */
|
|
15
|
+
counter(name: string, value: number, tags?: MetricTags): void;
|
|
16
|
+
/** Latency distribution (e.g. request duration in ms). */
|
|
17
|
+
histogram(name: string, value: number, tags?: MetricTags): void;
|
|
18
|
+
/** Point-in-time gauge (e.g. current in-flight requests, queue depth). */
|
|
19
|
+
gauge(name: string, value: number, tags?: MetricTags): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Standard metric names
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export const METRICS = {
|
|
27
|
+
/** Total requests initiated. Tags: partition, method. */
|
|
28
|
+
REQUESTS: "vereda.requests",
|
|
29
|
+
/** Total retries executed. Tags: partition, kind. */
|
|
30
|
+
RETRIES: "vereda.retries",
|
|
31
|
+
/** Request duration in ms (histogram). Tags: partition (omitted when the URL
|
|
32
|
+
* never resolved), kind. */
|
|
33
|
+
DURATION: "vereda.duration_ms",
|
|
34
|
+
/** Current queue depth per partition. Tags: partition. */
|
|
35
|
+
QUEUE_DEPTH: "vereda.queue_depth",
|
|
36
|
+
/** Current in-flight requests across all partitions. */
|
|
37
|
+
IN_FLIGHT: "vereda.in_flight",
|
|
38
|
+
/** Callers currently waiting for a global concurrency permit (the D1 cap). */
|
|
39
|
+
GLOBAL_QUEUE_DEPTH: "vereda.global_queue_depth",
|
|
40
|
+
/** Total circuit breaker trips to open. Tags: partition. */
|
|
41
|
+
CIRCUIT_OPEN: "vereda.circuit_open",
|
|
42
|
+
} as const;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function nanoid(size = 21): string {
|
|
4
|
+
const bytes = randomBytes(size);
|
|
5
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
|
6
|
+
let id = "";
|
|
7
|
+
for (let i = 0; i < size; i++) {
|
|
8
|
+
id += chars[bytes[i]! & 63];
|
|
9
|
+
}
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// URL redaction for safe logging
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
/** Userinfo in an absolute URL's authority: `scheme://user:pass@`. Greedy up
|
|
6
|
+
* to the last `@` before the path/query/fragment, as the URL spec parses it. */
|
|
7
|
+
const USERINFO = /^([a-zA-Z][a-zA-Z\d+.-]*:\/\/)[^/?#]*@/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redacts the secret-bearing parts of a URL for safe logging: userinfo
|
|
11
|
+
* credentials become `[redacted]@`, and each query parameter value becomes
|
|
12
|
+
* `[redacted]` while its key is preserved.
|
|
13
|
+
*
|
|
14
|
+
* Example: `https://bob:hunter2@api.example.com/auth?token=abc123&user=joe`
|
|
15
|
+
* → `https://[redacted]@api.example.com/auth?token=[redacted]&user=[redacted]`
|
|
16
|
+
*
|
|
17
|
+
* URLs with neither are returned unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function redactUrl(url: string): string {
|
|
20
|
+
const withoutUserinfo = url.replace(USERINFO, "$1[redacted]@");
|
|
21
|
+
return redactQueryValues(withoutUserinfo);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function redactQueryValues(url: string): string {
|
|
25
|
+
const qIdx = url.indexOf("?");
|
|
26
|
+
if (qIdx === -1) return url;
|
|
27
|
+
|
|
28
|
+
const base = url.slice(0, qIdx);
|
|
29
|
+
const rest = url.slice(qIdx + 1);
|
|
30
|
+
|
|
31
|
+
// Separate fragment from query string (# is the fragment delimiter)
|
|
32
|
+
const fIdx = rest.indexOf("#");
|
|
33
|
+
const query = fIdx === -1 ? rest : rest.slice(0, fIdx);
|
|
34
|
+
const fragment = fIdx === -1 ? "" : rest.slice(fIdx);
|
|
35
|
+
|
|
36
|
+
const redacted = query
|
|
37
|
+
.split("&")
|
|
38
|
+
.map((pair) => {
|
|
39
|
+
const eqIdx = pair.indexOf("=");
|
|
40
|
+
if (eqIdx === -1) return pair; // bare key, no value
|
|
41
|
+
return `${pair.slice(0, eqIdx + 1)}[redacted]`;
|
|
42
|
+
})
|
|
43
|
+
.join("&");
|
|
44
|
+
|
|
45
|
+
return `${base}?${redacted}${fragment}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { AppError } from "./errors.ts";
|
|
2
|
+
import type { MetricsSink } from "./metrics.ts";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Result type
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export type Result<T> = { success: true; data: T; raw: Response } | { success: false; error: AppError };
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Parse function — schema-agnostic
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export type ParseFn<T> = (data: unknown) => T;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Backoff
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export type BackoffFn = (attempt: number) => number;
|
|
21
|
+
|
|
22
|
+
export interface BackoffOptions {
|
|
23
|
+
/** Base delay in ms. @default {@link DEFAULT_BASE_DELAY_MS} */
|
|
24
|
+
baseDelayMs?: number;
|
|
25
|
+
/** Maximum delay cap in ms. @default {@link DEFAULT_MAX_DELAY_MS} */
|
|
26
|
+
maxDelayMs?: number;
|
|
27
|
+
/** Whether to add jitter. @default {@link DEFAULT_JITTER} */
|
|
28
|
+
jitter?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Timeout config
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface TimeoutConfig {
|
|
36
|
+
/** Per-attempt timeout in ms. Omit to inherit the client-level default;
|
|
37
|
+
* `Infinity` explicitly means no per-attempt cap. Also bounds, from
|
|
38
|
+
* attempt start, how long the caller has to read the body of a Response
|
|
39
|
+
* handed back unread (a success without `parse`, or an `HttpError`'s
|
|
40
|
+
* `.response`) before that read is aborted. */
|
|
41
|
+
attemptMs?: number;
|
|
42
|
+
/** Whole-ticket deadline in ms. Starts at request(); cancels the ticket
|
|
43
|
+
* and resolves with DeadlineExceededError on expiry. Omit (or pass
|
|
44
|
+
* `Infinity`) for no total deadline. Also caps how long the caller has
|
|
45
|
+
* to read an unread Response's body handed back as above, if that's
|
|
46
|
+
* sooner than `attemptMs` would otherwise allow. */
|
|
47
|
+
totalMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `ClientConfig.timeout` variant where `attemptMs` is mandatory. Every
|
|
51
|
+
* client must explicitly decide its per-attempt timeout — `Infinity` is a
|
|
52
|
+
* legal, deliberate choice to opt out of a cap. This is the one default in
|
|
53
|
+
* the library that cannot safely be implicit: every other default (retries,
|
|
54
|
+
* concurrency, queue sizes) fails safe when omitted; an omitted timeout
|
|
55
|
+
* fails unbounded. Partition- and request-level `timeout` stay optional —
|
|
56
|
+
* they inherit this client-level decision unless they override it. */
|
|
57
|
+
export interface ClientTimeoutConfig extends Omit<TimeoutConfig, "attemptMs"> {
|
|
58
|
+
attemptMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when `ms` is a real, finite bound — not omitted and not `Infinity`
|
|
62
|
+
* (the explicit "no cap" value). Use instead of `!== undefined` wherever a
|
|
63
|
+
* timeout/deadline value is checked, since `Infinity` must be treated the
|
|
64
|
+
* same as "not set" everywhere a timer would otherwise be created. */
|
|
65
|
+
export function isBoundedMs(ms: number | undefined): ms is number {
|
|
66
|
+
return ms !== undefined && Number.isFinite(ms);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Default retry-on-status codes (D1)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_RETRY_ON_STATUS: number[] = [408, 425, 429, 500, 502, 503, 504];
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Partition / bulkhead config
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Default per-partition concurrency limit (decision D1). */
|
|
80
|
+
export const DEFAULT_CONCURRENCY = 5;
|
|
81
|
+
/** Default max queued items per partition before rejecting new ones. */
|
|
82
|
+
export const DEFAULT_MAX_QUEUE_SIZE = 100;
|
|
83
|
+
|
|
84
|
+
export interface PartitionConfig {
|
|
85
|
+
/** Max concurrent in-flight retries for this partition.
|
|
86
|
+
* @default {@link DEFAULT_CONCURRENCY} */
|
|
87
|
+
concurrency?: number;
|
|
88
|
+
/** Max number of pending items in the queue before rejecting new ones.
|
|
89
|
+
* @default {@link DEFAULT_MAX_QUEUE_SIZE} */
|
|
90
|
+
maxQueueSize?: number;
|
|
91
|
+
/** When true, the first attempt also goes through the bulkhead (R6).
|
|
92
|
+
* @default false */
|
|
93
|
+
limitFirstAttempts?: boolean;
|
|
94
|
+
retry?: RetryConfig;
|
|
95
|
+
timeout?: TimeoutConfig;
|
|
96
|
+
circuitBreaker?: CircuitBreakerConfig;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Circuit breaker config
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** Default consecutive-failure count that trips the circuit open. */
|
|
104
|
+
export const DEFAULT_FAILURE_THRESHOLD = 5;
|
|
105
|
+
/** Default ms to stay open before allowing a half-open trial. */
|
|
106
|
+
export const DEFAULT_RESET_TIMEOUT_MS = 30_000;
|
|
107
|
+
/** Default number of concurrent trial requests allowed while half-open. */
|
|
108
|
+
export const DEFAULT_HALF_OPEN_MAX_ATTEMPTS = 1;
|
|
109
|
+
|
|
110
|
+
export interface CircuitBreakerConfig {
|
|
111
|
+
/** @default false */
|
|
112
|
+
enabled?: boolean;
|
|
113
|
+
/** Consecutive-failure trip mode (default strategy).
|
|
114
|
+
* @default {@link DEFAULT_FAILURE_THRESHOLD} */
|
|
115
|
+
failureThreshold?: number;
|
|
116
|
+
/** Rolling-window trip mode. If set, used INSTEAD of failureThreshold. */
|
|
117
|
+
window?: {
|
|
118
|
+
sizeMs: number;
|
|
119
|
+
failureRatePercent: number;
|
|
120
|
+
minimumRequests: number;
|
|
121
|
+
};
|
|
122
|
+
/** ms to stay open before allowing a half-open trial.
|
|
123
|
+
* @default {@link DEFAULT_RESET_TIMEOUT_MS} */
|
|
124
|
+
resetTimeoutMs?: number;
|
|
125
|
+
/** concurrent trial requests allowed while half-open.
|
|
126
|
+
* @default {@link DEFAULT_HALF_OPEN_MAX_ATTEMPTS} */
|
|
127
|
+
halfOpenMaxAttempts?: number;
|
|
128
|
+
/** Override default failure classification (network/timeout/retryable_status).
|
|
129
|
+
* Not consulted for an error that shows the attempt never reached the host
|
|
130
|
+
* (e.g. a body factory that threw before the request was dispatched) —
|
|
131
|
+
* those are always ignored, since there is no host-health signal to classify. */
|
|
132
|
+
isFailure?: (error: AppError) => boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Retry config
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** Default number of retries after the first attempt. */
|
|
140
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
141
|
+
|
|
142
|
+
export interface RetryConfig {
|
|
143
|
+
/** Retries after the first attempt. `MaxRetriesExceededError.attempts` is
|
|
144
|
+
* total executions, i.e. `maxRetries + 1`.
|
|
145
|
+
* @default {@link DEFAULT_MAX_RETRIES} */
|
|
146
|
+
maxRetries?: number;
|
|
147
|
+
/** @default `{ baseDelayMs: 200, maxDelayMs: 30_000, jitter: true }` */
|
|
148
|
+
backoff?: BackoffFn | BackoffOptions;
|
|
149
|
+
/** HTTP status codes that trigger retry (e.g. 408, 429, 500, 502, 503, 504).
|
|
150
|
+
* @default {@link DEFAULT_RETRY_ON_STATUS} */
|
|
151
|
+
retryOnStatus?: number[];
|
|
152
|
+
/** Allows retrying non-idempotent methods (POST/PATCH/CONNECT). An
|
|
153
|
+
* `Idempotency-Key` header also enables retries. Default: false. */
|
|
154
|
+
idempotent?: boolean;
|
|
155
|
+
/** Optional predicate to decide whether a failed attempt should be retried.
|
|
156
|
+
* Called exactly once per failed attempt that could still be retried, with
|
|
157
|
+
* the zero-based attempt number:
|
|
158
|
+
* - 0 = the first attempt (called client-side before the retry loop)
|
|
159
|
+
* - 1, 2, … = retries (called inside the loop, after attempt 0)
|
|
160
|
+
* Returning `false` surfaces the error immediately without retrying. Not
|
|
161
|
+
* called after the final attempt when no retries remain — there is
|
|
162
|
+
* nothing left to decide. */
|
|
163
|
+
retryWhen?: (error: AppError, attempt: number) => boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Logger
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export interface Logger {
|
|
171
|
+
debug(msg: string, meta?: Record<string, unknown>): void;
|
|
172
|
+
info(msg: string, meta?: Record<string, unknown>): void;
|
|
173
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
174
|
+
error(msg: string, meta?: Record<string, unknown>): void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Lifecycle events
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
export type LifecycleEventMap = {
|
|
182
|
+
request: { ticketId: string; url: string; method: string; partition: string };
|
|
183
|
+
/** Zero-based retry index (0 = first retry after the initial attempt).
|
|
184
|
+
* Note: retryWhen's attempt parameter uses a different numbering —
|
|
185
|
+
* 0 = first attempt, 1 = first retry, etc. */
|
|
186
|
+
retry: {
|
|
187
|
+
ticketId: string;
|
|
188
|
+
url: string;
|
|
189
|
+
partition: string;
|
|
190
|
+
attempt: number;
|
|
191
|
+
delayMs: number;
|
|
192
|
+
error: AppError;
|
|
193
|
+
};
|
|
194
|
+
success: {
|
|
195
|
+
ticketId: string;
|
|
196
|
+
url: string;
|
|
197
|
+
partition: string;
|
|
198
|
+
attempts: number;
|
|
199
|
+
durationMs: number;
|
|
200
|
+
queuedMs: number;
|
|
201
|
+
statusCode: number;
|
|
202
|
+
};
|
|
203
|
+
failure: {
|
|
204
|
+
ticketId: string;
|
|
205
|
+
url: string;
|
|
206
|
+
/** `undefined` only when the URL couldn't be resolved, so no partition
|
|
207
|
+
* was ever chosen (no `request` event is emitted in that case either). */
|
|
208
|
+
partition: string | undefined;
|
|
209
|
+
attempts: number;
|
|
210
|
+
durationMs: number;
|
|
211
|
+
queuedMs: number;
|
|
212
|
+
error: AppError;
|
|
213
|
+
};
|
|
214
|
+
cancelled: {
|
|
215
|
+
ticketId: string;
|
|
216
|
+
url: string;
|
|
217
|
+
partition: string;
|
|
218
|
+
attempts: number;
|
|
219
|
+
durationMs: number;
|
|
220
|
+
queuedMs: number;
|
|
221
|
+
};
|
|
222
|
+
circuitOpen: { partition: string };
|
|
223
|
+
circuitClose: { partition: string };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Request options
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export interface RequestOptions<T = unknown> {
|
|
231
|
+
/** @default "GET" */
|
|
232
|
+
method?: string;
|
|
233
|
+
headers?: HeadersInit;
|
|
234
|
+
/** Request body, or a factory that returns a fresh body on every attempt
|
|
235
|
+
* (replayable body). A ReadableStream must be supplied via a factory. The
|
|
236
|
+
* factory must return a fresh body each invocation — reusing the same
|
|
237
|
+
* ReadableStream replays an already-consumed (empty) stream. */
|
|
238
|
+
body?: BodyInit | (() => BodyInit);
|
|
239
|
+
/** Named bulkhead partition. Defaults to `host` (hostname + port when non-default). */
|
|
240
|
+
partition?: string;
|
|
241
|
+
/** Schema parse function. Use withZod() or custom. */
|
|
242
|
+
parse?: ParseFn<T>;
|
|
243
|
+
retry?: RetryConfig;
|
|
244
|
+
timeout?: TimeoutConfig;
|
|
245
|
+
/** Signal to cancel the request externally */
|
|
246
|
+
signal?: AbortSignal;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Request options with `parse` set: the ticket resolves with `data: T`. */
|
|
250
|
+
export type ParsedRequestOptions<T> = Omit<RequestOptions<T>, "parse"> & { parse: ParseFn<T> };
|
|
251
|
+
|
|
252
|
+
/** Request options without `parse`: the body is left unread on `raw` and
|
|
253
|
+
* `data` is `undefined`. `parse: undefined` is accepted so options can be
|
|
254
|
+
* built conditionally. */
|
|
255
|
+
export type UnparsedRequestOptions = Omit<RequestOptions, "parse"> & { parse?: undefined };
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Client config
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/** Default global concurrency cap across all partitions (decision D1). */
|
|
262
|
+
export const DEFAULT_GLOBAL_CONCURRENCY = 50;
|
|
263
|
+
/** Default max requests queued globally, across all partitions, waiting for
|
|
264
|
+
* a concurrency permit before new requests are rejected with QueueFullError. */
|
|
265
|
+
export const DEFAULT_GLOBAL_QUEUE_SIZE = 100;
|
|
266
|
+
|
|
267
|
+
export interface ClientConfig {
|
|
268
|
+
/** Base URL prepended to all requests */
|
|
269
|
+
baseUrl?: string;
|
|
270
|
+
/** Default retry config */
|
|
271
|
+
retry?: RetryConfig;
|
|
272
|
+
/** Default timeout config. `attemptMs` is required — pass `Infinity` to
|
|
273
|
+
* explicitly opt out of a per-attempt cap, so "no timeout" is always a
|
|
274
|
+
* deliberate choice rather than an accidental default. */
|
|
275
|
+
timeout: ClientTimeoutConfig;
|
|
276
|
+
/** Global concurrency across all partitions.
|
|
277
|
+
* @default {@link DEFAULT_GLOBAL_CONCURRENCY} */
|
|
278
|
+
concurrency?: number;
|
|
279
|
+
/** Max requests queued globally (across all partitions) waiting for a
|
|
280
|
+
* concurrency permit before new requests are rejected with QueueFullError.
|
|
281
|
+
* @default {@link DEFAULT_GLOBAL_QUEUE_SIZE} */
|
|
282
|
+
maxQueueSize?: number;
|
|
283
|
+
/** Per-partition overrides. Partitions not listed here use
|
|
284
|
+
* `{ concurrency: 5, maxQueueSize: 100 }`.
|
|
285
|
+
* @default {} */
|
|
286
|
+
partitions?: Record<string, PartitionConfig>;
|
|
287
|
+
/** Default circuit breaker config. Opt-in — inert unless `enabled: true`. */
|
|
288
|
+
circuitBreaker?: CircuitBreakerConfig;
|
|
289
|
+
/** Optional structured logger */
|
|
290
|
+
logger?: Logger;
|
|
291
|
+
/** Optional metrics sink for counters, histograms, and gauges. */
|
|
292
|
+
metrics?: MetricsSink;
|
|
293
|
+
/** Redact query parameter values in logged URLs.
|
|
294
|
+
* @default true */
|
|
295
|
+
redactQuery?: boolean;
|
|
296
|
+
/** Custom fetch function (defaults to globalThis.fetch). */
|
|
297
|
+
fetch?: typeof globalThis.fetch;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Options for `HttpClient.close()`.
|
|
301
|
+
*
|
|
302
|
+
* Modelled as a union so the type mirrors the runtime contract: `timeoutMs` is
|
|
303
|
+
* required only when draining. A single `{ drain?: boolean; timeoutMs: number }`
|
|
304
|
+
* shape made `close({ drain: false })` a type error even though it is the
|
|
305
|
+
* documented, working way to close without draining. */
|
|
306
|
+
export type CloseOptions = { drain?: false; timeoutMs?: number } | { drain: true; timeoutMs: number };
|