@theholocron/http-client 1.54.1 → 1.55.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/dist/index.d.mts CHANGED
@@ -94,6 +94,14 @@ interface RestClientConfig {
94
94
  * Defaults to a no-op.
95
95
  */
96
96
  errors?: ErrorSink;
97
+ /**
98
+ * How many times to send a request again after a transport failure that is safe to repeat (see `worthRetrying`):
99
+ * the connection never opened, or a read was dropped. HTTP error statuses are never retried. Defaults to 2; 0
100
+ * turns it off.
101
+ */
102
+ retries?: number;
103
+ /** Milliseconds before the first retry; each later one waits three times as long. Defaults to 300. */
104
+ retryDelayMs?: number;
97
105
  }
98
106
  interface RequestOptions {
99
107
  method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
package/dist/index.mjs CHANGED
@@ -47,12 +47,55 @@ var ProviderApiError = class extends Error {
47
47
  }
48
48
  };
49
49
  //#endregion
50
+ //#region src/transport.ts
51
+ /**
52
+ * Node's `fetch` rejects with a bare `TypeError: fetch failed`; the reason (a DNS miss, a connect timeout, a reset) is on
53
+ * `cause`, and for a host with both IPv4 and IPv6 addresses it is one level deeper, in an `AggregateError`.
54
+ */
55
+ function transportCode(err) {
56
+ const cause = err instanceof Error ? err.cause : void 0;
57
+ if (!cause || typeof cause !== "object") return void 0;
58
+ const { code, errors } = cause;
59
+ if (typeof code === "string") return code;
60
+ if (Array.isArray(errors)) {
61
+ const inner = errors.find((e) => typeof e?.code === "string");
62
+ if (inner) return inner.code;
63
+ }
64
+ }
65
+ /** Failures that happen before a request reaches the server, so retrying cannot apply it twice, whatever the method. */
66
+ const NEVER_SENT = /* @__PURE__ */ new Set([
67
+ "UND_ERR_CONNECT_TIMEOUT",
68
+ "ENOTFOUND",
69
+ "EAI_AGAIN",
70
+ "ECONNREFUSED",
71
+ "ENETUNREACH",
72
+ "EHOSTUNREACH"
73
+ ]);
74
+ /** Failures after the connection was made, where the server may or may not have seen the request. */
75
+ const DROPPED = /* @__PURE__ */ new Set([
76
+ "ECONNRESET",
77
+ "EPIPE",
78
+ "UND_ERR_SOCKET",
79
+ "ETIMEDOUT"
80
+ ]);
81
+ /**
82
+ * Whether a failed request is worth sending again: always when it never left, and for a read when the connection
83
+ * dropped (a repeat read changes nothing). A write that may have arrived is never repeated.
84
+ */
85
+ function worthRetrying(code, method) {
86
+ if (!code) return false;
87
+ if (NEVER_SENT.has(code)) return true;
88
+ return DROPPED.has(code) && (method === "GET" || method === "HEAD");
89
+ }
90
+ //#endregion
50
91
  //#region src/rest-client.ts
51
92
  function createRestClient(config) {
52
93
  const fetchImpl = config.fetch ?? globalThis.fetch;
53
94
  const vendor = config.vendor ?? "";
54
95
  const logger = config.logger ?? new NoopLogger();
55
96
  const errors = config.errors ?? new NoopErrorSink();
97
+ const maxRetries = config.retries ?? 2;
98
+ const retryDelay = config.retryDelayMs ?? 300;
56
99
  let baseUrl = config.baseUrl;
57
100
  while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
58
101
  const staticHeaders = { accept: "application/json" };
@@ -82,10 +125,23 @@ function createRestClient(config) {
82
125
  path
83
126
  }, "request");
84
127
  let res;
85
- try {
128
+ for (let attempt = 0;; attempt++) try {
86
129
  res = await fetchImpl(url.toString(), init);
130
+ break;
87
131
  } catch (err) {
88
- const apiErr = new ProviderApiError(`${tag} failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`, 0, void 0);
132
+ const code = transportCode(err);
133
+ if (attempt < maxRetries && worthRetrying(code, init.method)) {
134
+ logger.debug({
135
+ vendor,
136
+ method: init.method,
137
+ path,
138
+ code,
139
+ attempt: attempt + 1
140
+ }, "retrying");
141
+ await new Promise((resolve) => setTimeout(resolve, retryDelay * 3 ** attempt));
142
+ continue;
143
+ }
144
+ const apiErr = new ProviderApiError(`${tag} failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}${code ? ` (${code})` : ""}`, 0, void 0);
89
145
  errors.captureException(apiErr);
90
146
  throw apiErr;
91
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/http-client",
3
- "version": "1.54.1",
3
+ "version": "1.55.0",
4
4
  "description": "Shared HTTP primitives for theholocron — REST client factory, auth resolver, and error types",
5
5
  "keywords": [
6
6
  "typescript",