@dunx/http 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,11 +64,12 @@ The guide is canonical for every row; this table is the index.
64
64
  | WebSocket gateways | `@Gateway`, handlers, `PubSub`, multi-node relay | [WebSockets](../../docs/guide/09-websockets.md) |
65
65
  | Request logging | One structured entry per request, on by default | [Logging](../../docs/guide/13-logging.md) |
66
66
  | Trace context | W3C `traceparent` adopted and propagated, on by default | [Logging](../../docs/guide/13-logging.md) |
67
- | Metrics | Per-route counts and timings, off by default | [Metrics](../../docs/guide/22-metrics.md) |
68
- | Health and draining | `/health/live`, `/health/ready`, readiness during a rollout | [Health checks](../../docs/guide/20-health-checks.md) |
67
+ | Metrics | Per-route counts and timings, off by default | [Metrics](../../docs/guide/23-metrics.md) |
68
+ | Health and draining | `/health/live`, `/health/ready`, readiness during a rollout | [Health checks](../../docs/guide/21-health-checks.md) |
69
69
  | Throttling | `@Throttle`, `@SkipThrottle`, memory and Redis counters | [Middleware and guards](../../docs/guide/08-middleware-and-guards.md) |
70
- | Static files | `Bun.file` behind a mount, with a cache policy | [Deployment](../../docs/guide/19-deployment.md) |
71
- | Compression | zstd and gzip on Bun's own compressors | [Deployment](../../docs/guide/19-deployment.md) |
70
+ | Outbound resilience | `HttpRetryClassifier`: which statuses retry, and `Retry-After` | [Resilience](../../docs/guide/25-resilience.md) |
71
+ | Static files | `Bun.file` behind a mount, with a cache policy | [Deployment](../../docs/guide/20-deployment.md) |
72
+ | Compression | zstd and gzip on Bun's own compressors | [Deployment](../../docs/guide/20-deployment.md) |
72
73
 
73
74
  ## Subpaths
74
75
 
@@ -8,17 +8,7 @@
8
8
  * the caller and should say so.
9
9
  */
10
10
  export declare const safeStringify: (value: unknown) => string;
11
- /**
12
- * A plain object: `{}`, `Object.create(null)`, or a JSON-parsed value. Anything
13
- * with its own prototype - `Date`, `Map`, `Error`, a class instance - is not one.
14
- *
15
- * The prototype check rather than the reference's `typeof === 'object' && !Array
16
- * && !(instanceof Error)`, which answered `true` for a `Date` and for every class
17
- * instance, so "is this a plain object" did not mean what it said. Body routing
18
- * does not use this - see {@link isJsonBody} - so tightening it changes no
19
- * behaviour beyond making the predicate honest.
20
- */
21
- export declare const isPlainObject: (value: unknown) => value is Record<string, unknown>;
11
+ export { isPlainObject } from '@dunx/core';
22
12
  /**
23
13
  * Whether a payload should be JSON-encoded, or handed to `fetch` as-is.
24
14
  *
@@ -32,3 +22,14 @@ export declare const isPlainObject: (value: unknown) => value is Record<string,
32
22
  * JSON-encodable; a `Blob` is neither.
33
23
  */
34
24
  export declare const isJsonBody: (payload: unknown) => boolean;
25
+ /**
26
+ * JSON when the upstream said so or the body parses; text otherwise; undefined
27
+ * for empty.
28
+ *
29
+ * **A failed read rejects.** It used to be caught and reported as an empty body,
30
+ * so a 2xx whose body died mid-stream reached the caller as a success with no
31
+ * data, and a retry policy saw nothing to retry. A caller that wants the old
32
+ * behaviour asks for it: the two error paths in `HttpService` do, because there
33
+ * the status is the signal and an unreadable body should not replace it.
34
+ */
35
+ export declare const readBody: (response: Response) => Promise<unknown>;
@@ -1,4 +1,4 @@
1
- import type { RetryOptions } from './retry.js';
1
+ import type { HttpRetryOptions } from './retry.js';
2
2
  /**
3
3
  * Named `HttpClientOptions`, not `HttpOptions`: the server half already exports
4
4
  * that from `@dunx/http` for `HttpFactory.create`, and two things called
@@ -15,7 +15,7 @@ export interface HttpClientOptionsInit {
15
15
  readonly timeoutMs?: number;
16
16
  /** Sent on every request, under anything a call sets itself. */
17
17
  readonly headers?: Readonly<Record<string, string>>;
18
- readonly retry?: RetryOptions<unknown>;
18
+ readonly retry?: HttpRetryOptions;
19
19
  /**
20
20
  * Forward W3C Trace Context upstream as `traceparent`, so the callee's spans
21
21
  * join this request's trace and one trace spans both services.
@@ -71,7 +71,7 @@ export declare class HttpClientOptions {
71
71
  readonly baseUrl: string | undefined;
72
72
  readonly timeoutMs: number;
73
73
  readonly headers: Readonly<Record<string, string>>;
74
- readonly retry: RetryOptions<unknown>;
74
+ readonly retry: HttpRetryOptions;
75
75
  readonly propagateTrace: boolean;
76
76
  readonly name: string | undefined;
77
77
  readonly fetchOptions: Readonly<Record<string, unknown>>;
@@ -1,49 +1,35 @@
1
- export interface BackoffOptions {
2
- /** Base delay, doubled each attempt. */
3
- readonly baseMs: number;
4
- /** @default 2 */
5
- readonly power?: number;
6
- /** Upper bound of the random component added to each delay. @default 1000 */
7
- readonly jitterMs?: number;
8
- /** @default 30000 */
9
- readonly maxMs?: number;
10
- }
11
- /** `base * power^attempt + jitter`, capped. `attempt` is 0 for the first retry. */
12
- export declare const backoffDelay: (attempt: number, { baseMs, power, jitterMs, maxMs }: BackoffOptions) => number;
1
+ import { RetryClassifier, type RetryOptions, type RetryVerdict } from '@dunx/core';
13
2
  /**
14
3
  * The wait an upstream asked for, in ms, or undefined.
15
4
  *
16
5
  * RFC 9110 allows either a delay in seconds or an HTTP date, and both appear in
17
- * the wild - GitHub sends seconds, some CDNs send a date. Ignoring the header, as
18
- * the reference did, means retrying straight back into a rate limit that had just
19
- * told you exactly how long to wait.
6
+ * the wild: GitHub sends seconds, some CDNs send a date. Ignoring the header means
7
+ * retrying straight back into a rate limit that had just said how long to wait.
20
8
  */
21
9
  export declare const retryAfterMs: (headers: Headers, now?: number) => number | undefined;
22
10
  /**
23
11
  * Statuses worth trying again: a server that failed, one that is overloaded, and
24
- * one that timed out. Deliberately narrower than the source, which also retried
25
- * 409 and 422 - both of those are the server rejecting the *request*, and sending
26
- * it again unchanged gets the same answer.
12
+ * one that timed out. Narrower than 409 and 422, which are the server rejecting
13
+ * the *request* - sending it again unchanged gets the same answer.
27
14
  */
28
15
  export declare const isRetryableStatus: (status: number) => boolean;
29
- export interface RetryOptions<T> {
30
- /** Retries *after* the first attempt, so 3 means up to 4 calls. @default 3 */
31
- readonly maxRetries?: number;
32
- /** @default 1000 */
33
- readonly retryDelayMs?: number;
34
- readonly backoff?: Omit<BackoffOptions, 'baseMs'>;
16
+ /** Core's generic retry knobs plus the two an HTTP failure carries. */
17
+ export interface HttpRetryOptions<T = unknown> extends RetryOptions<T> {
35
18
  /** @default isRetryableStatus */
36
19
  readonly shouldRetryOnStatus?: (status: number) => boolean;
37
20
  /** Honour a `Retry-After` header over the computed backoff. @default true */
38
21
  readonly respectRetryAfter?: boolean;
39
- readonly onAttempt?: (attempt: number, isRetry: boolean) => void;
40
- readonly onError?: (error: unknown, attempt: number, willRetry: boolean) => void;
41
- readonly onSuccess?: (result: T, attempt: number) => void;
42
22
  }
43
23
  /**
44
- * Runs `operation`, retrying per `options`.
24
+ * The HTTP half of the retry decision, and the only place in dunx's resilience
25
+ * path that knows what a status is.
45
26
  *
46
- * `Bun.sleep` rather than a `setTimeout` promise: it is the runtime's own timer and
47
- * needs no wrapper.
27
+ * An abort is never retried: the caller's signal fired or the timeout expired, and
28
+ * both mean the budget for this call is spent. A transport failure is retried,
29
+ * because a refused connection is the case retrying exists for.
48
30
  */
49
- export declare const executeWithRetry: <T>(operation: () => Promise<T> | T, options?: RetryOptions<T>) => Promise<T>;
31
+ export declare class HttpRetryClassifier extends RetryClassifier {
32
+ private readonly options;
33
+ constructor(options?: HttpRetryOptions);
34
+ classify(error: unknown): RetryVerdict;
35
+ }
@@ -2,7 +2,7 @@ import { Logger, RequestContext } from '@dunx/core';
2
2
  import { UrlHelper, type ParamsType } from '@arkv/shared';
3
3
  import type { HttpMethod } from '../route/marker.js';
4
4
  import { HttpClientOptions } from './options.js';
5
- import { type RetryOptions } from './retry.js';
5
+ import { type HttpRetryOptions } from './retry.js';
6
6
  /** The client speaks two more verbs than a route can declare. */
7
7
  export type RequestMethod = HttpMethod | 'HEAD' | 'OPTIONS';
8
8
  /**
@@ -36,7 +36,7 @@ export interface RequestConfig<TRequest = unknown, TResponse = unknown> {
36
36
  readonly headerFactory?: HeaderFactory;
37
37
  /** Merged into the async context for this call, so its logs carry it. */
38
38
  readonly flow?: string;
39
- readonly retry?: RetryOptions<TResponse>;
39
+ readonly retry?: HttpRetryOptions<TResponse>;
40
40
  /** Cancels the call. Combined with the timeout, whichever fires first. */
41
41
  readonly signal?: AbortSignal;
42
42
  }
@@ -86,6 +86,12 @@ export declare class HttpService extends UrlHelper {
86
86
  * regex, so it cannot disagree with `new URL`.
87
87
  */
88
88
  private urlFor;
89
+ /**
90
+ * One policy per call, because the budget and the caller's signal are. Core owns
91
+ * the timeout, the loop and the backoff; `HttpRetryClassifier` is the only part
92
+ * of it that knows what a status is.
93
+ */
94
+ private policyFor;
89
95
  /** `serialised` is what a `headerFactory` signs, and is `''` for no body. */
90
96
  private bodyFor;
91
97
  private send;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The `data:` payloads of a server-sent-events body, in order, ending when the
3
+ * stream does or when a line reads `[DONE]`.
4
+ *
5
+ * Async iteration rather than `getReader()`: it acquires the reader and releases
6
+ * it on completion, on a `break` in the consumer, and on the `[DONE]` return,
7
+ * which is the case the manual form needed a `releaseLock()` in a `finally` for.
8
+ *
9
+ * Hand-rolled because Bun exposes no `EventSource` global and no SSE parser,
10
+ * which was measured rather than assumed.
11
+ */
12
+ export declare function sseData(body: ReadableStream<Uint8Array>): AsyncGenerator<string>;
13
+ /**
14
+ * A deadline on the connect alone.
15
+ *
16
+ * An `AbortSignal.timeout` handed to `fetch` keeps aborting after the headers
17
+ * arrive, which cut a 600 ms stream at 200 ms when the policy supplied one. This
18
+ * holds its timer so the caller can drop it the moment `fetch` resolves, leaving
19
+ * the body cancellable only by the caller's own signal.
20
+ */
21
+ export declare class ConnectDeadline {
22
+ #private;
23
+ constructor(ms: number, target: string);
24
+ get signal(): AbortSignal;
25
+ clear(): void;
26
+ }
package/dist/client.d.ts CHANGED
@@ -7,6 +7,13 @@
7
7
  */
8
8
  export { FetchError, FetchTransportError } from './client/errors.js';
9
9
  export { HttpClientOptions, type HttpClientOptionsInit, } from './client/options.js';
10
- export type { BackoffOptions, RetryOptions } from './client/retry.js';
10
+ /**
11
+ * Retry, backoff and jitter are `@dunx/core`'s, re-exported so an import of this
12
+ * subpath still names them. What stays here is the HTTP half of the decision:
13
+ * `HttpRetryClassifier` reads a status and a `Retry-After`, which is the seam that
14
+ * keeps both out of core.
15
+ */
16
+ export type { BackoffOptions, RetryOptions } from '@dunx/core';
17
+ export { HttpRetryClassifier, type HttpRetryOptions } from './client/retry.js';
11
18
  export { httpClient, HttpModule, type ClientTarget } from './client/module.js';
12
19
  export { HttpService, type HeaderFactory, type RequestConfig, type RequestMethod, } from './client/service.js';
package/dist/client.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // @bun
2
+ import {
3
+ HttpStatusCode2
4
+ } from "./chunk-bg0dr54z.js";
2
5
  import {
3
6
  TRACEPARENT_HEADER2,
4
7
  TRACESTATE_HEADER2,
5
8
  TraceContext2
6
9
  } from "./chunk-gmtwad7f.js";
7
- import {
8
- HttpStatusCode2
9
- } from "./chunk-bg0dr54z.js";
10
10
 
11
11
  // src/client/errors.ts
12
12
  import { AppError } from "@dunx/core";
@@ -71,6 +71,45 @@ class HttpClientOptions {
71
71
  }
72
72
  }
73
73
  Object.defineProperty(HttpClientOptions, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "init: HttpClientOptionsInit = {}", optional: true }] });
74
+ // src/client/retry.ts
75
+ import {
76
+ RetryClassifier
77
+ } from "@dunx/core";
78
+ var retryAfterMs = (headers, now = Date.now()) => {
79
+ const header = headers.get("retry-after");
80
+ if (header === null)
81
+ return;
82
+ const seconds = Number(header);
83
+ if (Number.isFinite(seconds))
84
+ return Math.max(0, seconds * 1000);
85
+ const at = Date.parse(header);
86
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now);
87
+ };
88
+ var isRetryableStatus = (status) => status >= HttpStatusCode2.INTERNAL_SERVER_ERROR || status === HttpStatusCode2.REQUEST_TIMEOUT || status === HttpStatusCode2.TOO_MANY_REQUESTS;
89
+
90
+ class HttpRetryClassifier extends RetryClassifier {
91
+ options;
92
+ constructor(options = {}) {
93
+ super();
94
+ this.options = options;
95
+ }
96
+ classify(error) {
97
+ if (error instanceof FetchTransportError)
98
+ return { retry: !error.aborted };
99
+ if (error instanceof FetchError) {
100
+ const {
101
+ shouldRetryOnStatus = isRetryableStatus,
102
+ respectRetryAfter = true
103
+ } = this.options;
104
+ if (!shouldRetryOnStatus(error.status))
105
+ return { retry: false };
106
+ const asked = respectRetryAfter ? retryAfterMs(error.response.headers) : undefined;
107
+ return asked === undefined ? { retry: true } : { retry: true, delayMs: asked };
108
+ }
109
+ return { retry: true };
110
+ }
111
+ }
112
+ Object.defineProperty(HttpRetryClassifier, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "private readonly options: HttpRetryOptions = {}", optional: true }] });
74
113
  // src/client/module.ts
75
114
  import {
76
115
  Logger as Logger2,
@@ -80,10 +119,16 @@ import {
80
119
  } from "@dunx/core";
81
120
 
82
121
  // src/client/service.ts
83
- import { Logger, RequestContext } from "@dunx/core";
122
+ import {
123
+ Logger,
124
+ RequestContext,
125
+ ResilienceOptions,
126
+ ResiliencePolicy
127
+ } from "@dunx/core";
84
128
  import { UrlHelper } from "@arkv/shared";
85
129
 
86
130
  // src/client/json.ts
131
+ import { isPlainObject } from "@dunx/core";
87
132
  var safeStringify = (value) => {
88
133
  const seen = new WeakSet;
89
134
  return JSON.stringify(value, (_key, entry) => {
@@ -102,69 +147,57 @@ var isJsonBody = (payload) => {
102
147
  return typeof payload !== "string";
103
148
  return !(payload instanceof FormData || payload instanceof URLSearchParams || payload instanceof Blob || payload instanceof ArrayBuffer || payload instanceof ReadableStream || ArrayBuffer.isView(payload));
104
149
  };
105
-
106
- // src/client/retry.ts
107
- var uniform = () => {
108
- const buffer = new Uint32Array(1);
109
- crypto.getRandomValues(buffer);
110
- return (buffer[0] ?? 0) / 2 ** 32;
111
- };
112
- var backoffDelay = (attempt, { baseMs, power = 2, jitterMs = 1000, maxMs = 30000 }) => Math.min(baseMs * power ** attempt + uniform() * jitterMs, maxMs);
113
- var retryAfterMs = (headers, now = Date.now()) => {
114
- const header = headers.get("retry-after");
115
- if (header === null)
150
+ var readBody = async (response) => {
151
+ const text = await response.text();
152
+ if (text === "")
116
153
  return;
117
- const seconds = Number(header);
118
- if (Number.isFinite(seconds))
119
- return Math.max(0, seconds * 1000);
120
- const at = Date.parse(header);
121
- return Number.isNaN(at) ? undefined : Math.max(0, at - now);
122
- };
123
- var isRetryableStatus = (status) => status >= HttpStatusCode2.INTERNAL_SERVER_ERROR || status === HttpStatusCode2.REQUEST_TIMEOUT || status === HttpStatusCode2.TOO_MANY_REQUESTS;
124
- var decide = (error, attempt, options) => {
125
- const {
126
- retryDelayMs = 1000,
127
- backoff,
128
- shouldRetryOnStatus = isRetryableStatus,
129
- respectRetryAfter = true
130
- } = options;
131
- const computed = backoffDelay(attempt, { baseMs: retryDelayMs, ...backoff });
132
- if (error instanceof FetchTransportError) {
133
- return { retry: !error.aborted, delayMs: computed };
134
- }
135
- if (error instanceof FetchError) {
136
- if (!shouldRetryOnStatus(error.status))
137
- return { retry: false, delayMs: 0 };
138
- const asked = respectRetryAfter ? retryAfterMs(error.response.headers) : undefined;
139
- const maxMs = backoff?.maxMs ?? 30000;
140
- return {
141
- retry: true,
142
- delayMs: asked === undefined ? computed : Math.min(asked, maxMs)
143
- };
154
+ try {
155
+ return JSON.parse(text);
156
+ } catch {
157
+ return text;
144
158
  }
145
- return { retry: true, delayMs: computed };
146
159
  };
147
- var executeWithRetry = async (operation, options = {}) => {
148
- const { maxRetries = 3, onAttempt, onError, onSuccess } = options;
149
- let lastError;
150
- for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
151
- onAttempt?.(attempt + 1, attempt > 0);
152
- try {
153
- const result = await operation();
154
- onSuccess?.(result, attempt + 1);
155
- return result;
156
- } catch (error) {
157
- lastError = error;
158
- const { retry, delayMs } = decide(error, attempt, options);
159
- const willRetry = retry && attempt < maxRetries;
160
- onError?.(error, attempt + 1, willRetry);
161
- if (!willRetry)
162
- throw error;
163
- await Bun.sleep(delayMs);
160
+
161
+ // src/client/sse.ts
162
+ async function* sseData(body) {
163
+ const decoder = new TextDecoder;
164
+ let buffer = "";
165
+ for await (const chunk of body) {
166
+ buffer += decoder.decode(chunk, { stream: true });
167
+ let newline = buffer.indexOf(`
168
+ `);
169
+ while (newline !== -1) {
170
+ const line = buffer.slice(0, newline).trim();
171
+ buffer = buffer.slice(newline + 1);
172
+ newline = buffer.indexOf(`
173
+ `);
174
+ if (!line.startsWith("data:"))
175
+ continue;
176
+ const data = line.slice(5).trim();
177
+ if (data === "[DONE]")
178
+ return;
179
+ yield data;
164
180
  }
165
181
  }
166
- throw lastError;
167
- };
182
+ }
183
+
184
+ class ConnectDeadline {
185
+ #controller = new AbortController;
186
+ #timer;
187
+ constructor(ms, target) {
188
+ this.#timer = ms > 0 ? setTimeout(() => {
189
+ this.#controller.abort(new DOMException(`Connecting to ${target} timed out after ${ms}ms`, "TimeoutError"));
190
+ }, ms) : undefined;
191
+ }
192
+ get signal() {
193
+ return this.#controller.signal;
194
+ }
195
+ clear() {
196
+ if (this.#timer !== undefined)
197
+ clearTimeout(this.#timer);
198
+ }
199
+ }
200
+ Object.defineProperty(ConnectDeadline, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "ms: number" }, { unresolved: "target: string" }] });
168
201
 
169
202
  // src/client/service.ts
170
203
  class HttpService extends UrlHelper {
@@ -184,12 +217,14 @@ class HttpService extends UrlHelper {
184
217
  let status;
185
218
  const { body, serialised } = this.bodyFor(config.payload);
186
219
  const replayable = !(config.payload instanceof ReadableStream);
187
- const attempt = async () => {
220
+ const attempt = async (signal) => {
188
221
  attempts += 1;
189
- const response = await this.send(config, url, body, serialised);
222
+ const response = await this.send(config, url, body, serialised, signal);
190
223
  status = response.status;
191
224
  if (!response.ok) {
192
- throw new FetchError(response.status, response.statusText, await readBody(response), {
225
+ throw new FetchError(response.status, response.statusText, await readBody(response).catch(() => {
226
+ return;
227
+ }), {
193
228
  method: config.method,
194
229
  url: url.href,
195
230
  headers: response.headers
@@ -198,15 +233,16 @@ class HttpService extends UrlHelper {
198
233
  return await readBody(response);
199
234
  };
200
235
  const describe = () => `${config.method} ${url.href}`;
236
+ const policy = this.policyFor(config, {
237
+ ...this.options.retry,
238
+ ...config.retry,
239
+ ...replayable ? {} : { maxRetries: 0 }
240
+ });
201
241
  try {
202
242
  const result = await this.requestContext.runWithContext({
203
243
  ...config.flow === undefined ? {} : { flow: config.flow },
204
244
  event: config.path ?? url.pathname
205
- }, () => executeWithRetry(attempt, {
206
- ...this.options.retry,
207
- ...config.retry,
208
- ...replayable ? {} : { maxRetries: 0 }
209
- }));
245
+ }, () => policy.run(attempt));
210
246
  this.logger.debug(`${describe()} succeeded`, {
211
247
  status,
212
248
  attempts,
@@ -265,30 +301,23 @@ class HttpService extends UrlHelper {
265
301
  const method = config.method ?? "POST";
266
302
  const startedAt = Date.now();
267
303
  const { body, serialised } = this.bodyFor(config.payload);
268
- const response = await this.send({ ...config, method }, url, body, serialised, "text/event-stream");
304
+ const deadline = new ConnectDeadline(config.timeoutMs ?? this.options.timeoutMs, url.href);
305
+ let response;
306
+ try {
307
+ const policy = this.policyFor({ ...config, timeoutMs: 0 }, {
308
+ maxRetries: 0
309
+ });
310
+ response = await policy.run((signal) => this.send({ ...config, method }, url, body, serialised, AbortSignal.any([signal, deadline.signal]), "text/event-stream"));
311
+ } finally {
312
+ deadline.clear();
313
+ }
269
314
  if (!response.ok || response.body === null) {
270
- throw new FetchError(response.status, response.statusText, await readBody(response), { method, url: url.href, headers: response.headers });
315
+ throw new FetchError(response.status, response.statusText, await readBody(response).catch(() => {
316
+ return;
317
+ }), { method, url: url.href, headers: response.headers });
271
318
  }
272
- const decoder = new TextDecoder;
273
- let buffer = "";
274
319
  try {
275
- for await (const chunk of response.body) {
276
- buffer += decoder.decode(chunk, { stream: true });
277
- let newline = buffer.indexOf(`
278
- `);
279
- while (newline !== -1) {
280
- const line = buffer.slice(0, newline).trim();
281
- buffer = buffer.slice(newline + 1);
282
- newline = buffer.indexOf(`
283
- `);
284
- if (!line.startsWith("data:"))
285
- continue;
286
- const data = line.slice(5).trim();
287
- if (data === "[DONE]")
288
- return;
289
- yield data;
290
- }
291
- }
320
+ yield* sseData(response.body);
292
321
  } finally {
293
322
  this.logger.debug(`SSE ${method} ${url.href} closed`, {
294
323
  elapsedMs: Date.now() - startedAt
@@ -313,6 +342,14 @@ class HttpService extends UrlHelper {
313
342
  ...config.queryParams === undefined ? {} : { queryParams: config.queryParams }
314
343
  });
315
344
  }
345
+ policyFor(config, retry) {
346
+ return new ResiliencePolicy(new ResilienceOptions({
347
+ timeoutMs: config.timeoutMs ?? this.options.timeoutMs,
348
+ ...config.signal === undefined ? {} : { signal: config.signal },
349
+ retry,
350
+ classifier: new HttpRetryClassifier(retry)
351
+ }));
352
+ }
316
353
  bodyFor(payload) {
317
354
  if (payload === undefined || payload === null) {
318
355
  return { body: undefined, serialised: "", json: false };
@@ -323,7 +360,7 @@ class HttpService extends UrlHelper {
323
360
  const serialised = JSON.stringify(payload);
324
361
  return { body: serialised, serialised, json: true };
325
362
  }
326
- async send(config, url, body, serialised, accept = "application/json") {
363
+ async send(config, url, body, serialised, signal, accept = "application/json") {
327
364
  const trace = this.options.propagateTrace ? this.requestContext.getContext() : undefined;
328
365
  const headers = {
329
366
  accept,
@@ -345,17 +382,12 @@ class HttpService extends UrlHelper {
345
382
  }),
346
383
  ...config.headers
347
384
  };
348
- const timeoutMs = config.timeoutMs ?? this.options.timeoutMs;
349
- const signals = [
350
- ...timeoutMs > 0 ? [AbortSignal.timeout(timeoutMs)] : [],
351
- ...config.signal === undefined ? [] : [config.signal]
352
- ];
353
385
  try {
354
386
  return await fetch(url.href, {
355
387
  method: config.method,
356
388
  headers,
357
389
  ...body === undefined ? {} : { body },
358
- ...signals.length === 0 ? {} : { signal: AbortSignal.any(signals) },
390
+ signal,
359
391
  ...this.options.fetchOptions
360
392
  });
361
393
  } catch (error) {
@@ -366,16 +398,6 @@ class HttpService extends UrlHelper {
366
398
  }
367
399
  Object.defineProperty(HttpService, Symbol.for("dunx.deps"), { value: () => [HttpClientOptions, Logger, RequestContext] });
368
400
  var urlOf = (url) => url === undefined ? {} : { url };
369
- var readBody = async (response) => {
370
- const text = await response.text().catch(() => "");
371
- if (text === "")
372
- return;
373
- try {
374
- return JSON.parse(text);
375
- } catch {
376
- return text;
377
- }
378
- };
379
401
  var describeError = (error) => {
380
402
  if (error instanceof FetchError) {
381
403
  return {
@@ -458,6 +480,7 @@ export {
458
480
  FetchTransportError,
459
481
  HttpClientOptions,
460
482
  HttpModule,
483
+ HttpRetryClassifier,
461
484
  HttpService,
462
485
  httpClient
463
486
  };
@@ -146,7 +146,7 @@ export interface HttpOptions extends AppOptions {
146
146
  * and never both. The pair without a `gatewayPort` is a boot error.
147
147
  *
148
148
  * `0` takes any free port and is the one value that does not warn when no
149
- * gateway is declared. See docs/guide/19-deployment.md.
149
+ * gateway is declared. See docs/guide/20-deployment.md.
150
150
  */
151
151
  readonly gatewayPort?: number;
152
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/http",
3
- "version": "3.5.1",
3
+ "version": "3.6.0",
4
4
  "description": "Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways",
5
5
  "keywords": [
6
6
  "bun",
@@ -65,7 +65,7 @@
65
65
  "@opentelemetry/sdk-trace-node": "2.11.0"
66
66
  },
67
67
  "peerDependencies": {
68
- "@dunx/core": "^3.5.1",
68
+ "@dunx/core": "^3.6.0",
69
69
  "@types/bun": ">=1.4.1"
70
70
  },
71
71
  "peerDependenciesMeta": {