@danmat/query-fetch 0.1.2 → 0.2.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
@@ -39,6 +39,7 @@ Scripted `fetch(url, { method: "QUERY", body })` already works in modern runtime
39
39
  - ✅ **Enforces `Content-Type`** — the RFC requires servers to reject a QUERY whose body has no content type. We throw *before* the round-trip instead of letting you debug a `400`.
40
40
  - ✅ **Transparent `POST` fallback** — servers that don't understand QUERY yet respond `405`/`501`; we automatically retry as `POST` and advertise the original method via `X-HTTP-Method-Override` so override-aware backends still route it correctly.
41
41
  - ✅ **`Accept` negotiation** — pass a media type (or list) to negotiate the response format the RFC's `Accept-Query` dance is built around.
42
+ - ✅ **Safe automatic retry** (opt-in) — QUERY is *idempotent by definition*, so retrying transient failures is safe here in a way it never is for `POST`. Exponential backoff + jitter, honours `Retry-After`.
42
43
  - ✅ **Redirect-safe** — the RFC's `303 See Other` indirect-result pattern is handled by `fetch`'s own redirect following; nothing surprising here.
43
44
  - ✅ **Zero dependencies, fully typed, tree-shakeable**, dual ESM/CJS.
44
45
 
@@ -69,6 +70,37 @@ await query("https://api.example.com/search", {
69
70
  });
70
71
  ```
71
72
 
73
+ ### Automatic retry (safe, because QUERY is idempotent)
74
+
75
+ RFC 10008 defines QUERY as safe and idempotent — so unlike `POST`, retrying a
76
+ failed request can't cause a double-effect. Opt in with a count, or an object
77
+ for full control:
78
+
79
+ ```ts
80
+ // Retry up to 3 times with exponential backoff + jitter.
81
+ await query(url, { json, retry: 3 });
82
+
83
+ // Full control.
84
+ await query(url, {
85
+ json,
86
+ retry: {
87
+ retries: 5,
88
+ minDelay: 200, // base backoff (ms)
89
+ maxDelay: 10_000,
90
+ factor: 2,
91
+ jitter: true,
92
+ respectRetryAfter: true, // honour Retry-After on 429/503
93
+ retryOn: ({ response, error }) =>
94
+ Boolean(error) || (response?.status ?? 0) >= 500,
95
+ onRetry: ({ attempt, delay }) => console.warn(`retry #${attempt} in ${delay}ms`),
96
+ },
97
+ });
98
+ ```
99
+
100
+ By default it retries network errors and `408/425/429/500/502/503/504`, and
101
+ does **not** retry aborts. Retry is off unless you set it. (Retries reuse a
102
+ buffered body — a string, bytes, or `json`; a streaming body is sent once.)
103
+
72
104
  ### Opt out of the POST fallback
73
105
 
74
106
  ```ts
@@ -97,6 +129,7 @@ Performs a QUERY request. `options` extends `RequestInit` (so `signal`, `credent
97
129
  | `accept` | `string \| string[]` | — | Sets the `Accept` header. |
98
130
  | `fallbackToPost` | `boolean` | `true` | Retry as `POST` on `405`/`501`. |
99
131
  | `methodOverrideHeader` | `string \| false` | `"X-HTTP-Method-Override"` | Header advertising the original method on fallback. |
132
+ | `retry` | `number \| RetryOptions` | off | Auto-retry transient failures (safe: QUERY is idempotent). |
100
133
  | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. |
101
134
 
102
135
  ### `queryJson<T>(input, options?): Promise<{ data: T; response: Response }>`
package/dist/index.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  var METHOD_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([405, 501]);
5
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
5
6
  var DEFAULT_OVERRIDE_HEADER = "X-HTTP-Method-Override";
6
7
  var QueryError = class _QueryError extends Error {
7
8
  name = "QueryError";
@@ -42,6 +43,70 @@ function prepare(options) {
42
43
  }
43
44
  return { headers, body };
44
45
  }
46
+ function isAbortError(error) {
47
+ return typeof error === "object" && error !== null && error.name === "AbortError";
48
+ }
49
+ function defaultRetryOn(context) {
50
+ if (context.error !== void 0) return !isAbortError(context.error);
51
+ if (context.response) return RETRYABLE_STATUSES.has(context.response.status);
52
+ return false;
53
+ }
54
+ function resolveRetry(retry) {
55
+ if (retry === void 0) return null;
56
+ const opts = typeof retry === "number" ? { retries: retry } : retry;
57
+ if (!opts.retries || opts.retries < 1) return null;
58
+ return {
59
+ retries: opts.retries,
60
+ minDelay: opts.minDelay ?? 200,
61
+ maxDelay: opts.maxDelay ?? 1e4,
62
+ factor: opts.factor ?? 2,
63
+ jitter: opts.jitter ?? true,
64
+ respectRetryAfter: opts.respectRetryAfter ?? true,
65
+ retryOn: opts.retryOn ?? defaultRetryOn,
66
+ onRetry: opts.onRetry
67
+ };
68
+ }
69
+ function parseRetryAfter(value) {
70
+ if (!value) return null;
71
+ const seconds = Number(value);
72
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
73
+ const date = Date.parse(value);
74
+ if (Number.isNaN(date)) return null;
75
+ return Math.max(0, date - Date.now());
76
+ }
77
+ function backoffDelay(cfg, attemptIndex, response) {
78
+ if (cfg.respectRetryAfter && response) {
79
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
80
+ if (retryAfter !== null) return retryAfter;
81
+ }
82
+ const base = Math.min(
83
+ cfg.maxDelay,
84
+ cfg.minDelay * cfg.factor ** attemptIndex
85
+ );
86
+ return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;
87
+ }
88
+ function abortError() {
89
+ const error = new Error("The operation was aborted.");
90
+ error.name = "AbortError";
91
+ return error;
92
+ }
93
+ function sleep(ms, signal) {
94
+ return new Promise((resolve, reject) => {
95
+ if (signal?.aborted) {
96
+ reject(abortError());
97
+ return;
98
+ }
99
+ const timer = setTimeout(resolve, ms);
100
+ signal?.addEventListener(
101
+ "abort",
102
+ () => {
103
+ clearTimeout(timer);
104
+ reject(abortError());
105
+ },
106
+ { once: true }
107
+ );
108
+ });
109
+ }
45
110
  async function query(input, options = {}) {
46
111
  const doFetch = resolveFetch(options.fetch);
47
112
  const { headers, body } = prepare(options);
@@ -50,31 +115,58 @@ async function query(input, options = {}) {
50
115
  json: _json,
51
116
  contentType: _contentType,
52
117
  accept: _accept,
118
+ retry: _retry,
53
119
  fallbackToPost = true,
54
120
  methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,
55
121
  headers: _headers,
56
122
  body: _body,
57
123
  ...init
58
124
  } = options;
59
- const response = await doFetch(input, {
60
- ...init,
61
- method: "QUERY",
62
- headers,
63
- body
64
- });
65
- if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
66
- return response;
67
- }
68
- const fallbackHeaders = new Headers(headers);
69
- if (methodOverrideHeader) {
70
- fallbackHeaders.set(methodOverrideHeader, "QUERY");
125
+ const attempt = async () => {
126
+ const response = await doFetch(input, {
127
+ ...init,
128
+ method: "QUERY",
129
+ headers,
130
+ body
131
+ });
132
+ if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
133
+ return response;
134
+ }
135
+ const fallbackHeaders = new Headers(headers);
136
+ if (methodOverrideHeader) {
137
+ fallbackHeaders.set(methodOverrideHeader, "QUERY");
138
+ }
139
+ return doFetch(input, {
140
+ ...init,
141
+ method: "POST",
142
+ headers: fallbackHeaders,
143
+ body
144
+ });
145
+ };
146
+ const retryCfg = resolveRetry(options.retry);
147
+ if (!retryCfg) return attempt();
148
+ const signal = init.signal ?? null;
149
+ for (let i = 0; ; i++) {
150
+ let response;
151
+ let caught;
152
+ try {
153
+ response = await attempt();
154
+ } catch (error) {
155
+ caught = error;
156
+ }
157
+ const isLastAttempt = i >= retryCfg.retries;
158
+ const context = { attempt: i + 1, response, error: caught };
159
+ if (caught !== void 0) {
160
+ if (isLastAttempt || isAbortError(caught) || !await retryCfg.retryOn(context)) {
161
+ throw caught;
162
+ }
163
+ } else if (isLastAttempt || !await retryCfg.retryOn(context)) {
164
+ return response;
165
+ }
166
+ const delay = backoffDelay(retryCfg, i, response);
167
+ retryCfg.onRetry?.({ ...context, delay });
168
+ await sleep(delay, signal);
71
169
  }
72
- return doFetch(input, {
73
- ...init,
74
- method: "POST",
75
- headers: fallbackHeaders,
76
- body
77
- });
78
170
  }
79
171
  async function queryJson(input, options = {}) {
80
172
  const response = await query(input, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAUA,IAAM,8CAA8B,IAAI,GAAA,CAAI,CAAC,GAAA,EAAK,GAAG,CAAC,CAAA;AAGtD,IAAM,uBAAA,GAA0B,wBAAA;AAMzB,IAAM,UAAA,GAAN,MAAM,WAAA,SAAmB,KAAA,CAAM;AAAA,EAC3B,IAAA,GAAO,YAAA;AAAA,EAEhB,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AAEb,IAAA,MAAA,CAAO,cAAA,CAAe,IAAA,EAAM,WAAA,CAAW,SAAS,CAAA;AAAA,EAClD;AACF;AAqDA,SAAS,aAAa,MAAA,EAAqC;AACzD,EAAA,MAAM,IAAA,GAAO,UAAU,UAAA,CAAW,KAAA;AAClC,EAAA,IAAI,OAAO,SAAS,UAAA,EAAY;AAC9B,IAAA,MAAM,IAAI,UAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,OAAO,IAAA;AACT;AAMA,SAAS,QAAQ,OAAA,EAGf;AACA,EAAA,MAAM,OAAA,GAAU,IAAI,OAAA,CAAQ,OAAA,CAAQ,OAAO,CAAA;AAE3C,EAAA,IAAI,OAAoC,OAAA,CAAQ,IAAA;AAEhD,EAAA,IAAI,IAAA,IAAQ,IAAA,IAAQ,OAAA,CAAQ,IAAA,KAAS,MAAA,EAAW;AAC9C,IAAA,IAAA,GAAO,IAAA,CAAK,SAAA,CAAU,OAAA,CAAQ,IAAI,CAAA;AAClC,IAAA,IAAI,CAAC,OAAA,CAAQ,GAAA,CAAI,cAAc,CAAA,EAAG;AAChC,MAAA,OAAA,CAAQ,GAAA,CAAI,gBAAgB,kBAAkB,CAAA;AAAA,IAChD;AAAA,EACF,CAAA,MAAA,IAAW,QAAQ,WAAA,EAAa;AAC9B,IAAA,OAAA,CAAQ,GAAA,CAAI,cAAA,EAAgB,OAAA,CAAQ,WAAW,CAAA;AAAA,EACjD;AAEA,EAAA,MAAM,UAAU,IAAA,IAAQ,IAAA;AACxB,EAAA,IAAI,OAAA,IAAW,CAAC,OAAA,CAAQ,GAAA,CAAI,cAAc,CAAA,EAAG;AAC3C,IAAA,MAAM,IAAI,UAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AAEA,EAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAW;AAChC,IAAA,MAAM,MAAA,GAAS,KAAA,CAAM,OAAA,CAAQ,OAAA,CAAQ,MAAM,CAAA,GACvC,OAAA,CAAQ,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,GACxB,OAAA,CAAQ,MAAA;AACZ,IAAA,OAAA,CAAQ,GAAA,CAAI,UAAU,MAAM,CAAA;AAAA,EAC9B;AAEA,EAAA,OAAO,EAAE,SAAS,IAAA,EAAK;AACzB;AAoBA,eAAsB,KAAA,CACpB,KAAA,EACA,OAAA,GAAwB,EAAC,EACN;AACnB,EAAA,MAAM,OAAA,GAAU,YAAA,CAAa,OAAA,CAAQ,KAAK,CAAA;AAC1C,EAAA,MAAM,EAAE,OAAA,EAAS,IAAA,EAAK,GAAI,QAAQ,OAAO,CAAA;AAEzC,EAAA,MAAM;AAAA,IACJ,KAAA,EAAO,MAAA;AAAA,IACP,IAAA,EAAM,KAAA;AAAA,IACN,WAAA,EAAa,YAAA;AAAA,IACb,MAAA,EAAQ,OAAA;AAAA,IACR,cAAA,GAAiB,IAAA;AAAA,IACjB,oBAAA,GAAuB,uBAAA;AAAA,IACvB,OAAA,EAAS,QAAA;AAAA,IACT,IAAA,EAAM,KAAA;AAAA,IACN,GAAG;AAAA,GACL,GAAI,OAAA;AAEJ,EAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,EAAO;AAAA,IACpC,GAAG,IAAA;AAAA,IACH,MAAA,EAAQ,OAAA;AAAA,IACR,OAAA;AAAA,IACA;AAAA,GACD,CAAA;AAED,EAAA,IAAI,CAAC,cAAA,IAAkB,CAAC,4BAA4B,GAAA,CAAI,QAAA,CAAS,MAAM,CAAA,EAAG;AACxE,IAAA,OAAO,QAAA;AAAA,EACT;AAIA,EAAA,MAAM,eAAA,GAAkB,IAAI,OAAA,CAAQ,OAAO,CAAA;AAC3C,EAAA,IAAI,oBAAA,EAAsB;AACxB,IAAA,eAAA,CAAgB,GAAA,CAAI,sBAAsB,OAAO,CAAA;AAAA,EACnD;AAEA,EAAA,OAAO,QAAQ,KAAA,EAAO;AAAA,IACpB,GAAG,IAAA;AAAA,IACH,MAAA,EAAQ,MAAA;AAAA,IACR,OAAA,EAAS,eAAA;AAAA,IACT;AAAA,GACD,CAAA;AACH;AAQA,eAAsB,SAAA,CACpB,KAAA,EACA,OAAA,GAAwB,EAAC,EACI;AAC7B,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,KAAA,EAAO;AAAA,IAClC,MAAA,EAAQ,kBAAA;AAAA,IACR,GAAG;AAAA,GACJ,CAAA;AAED,EAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,IAAA,MAAM,IAAI,UAAA;AAAA,MACR,CAAA,MAAA,EAAS,MAAM,QAAA,EAAU,uBAAuB,QAAA,CAAS,MAAM,CAAA,CAAA,EAAI,QAAA,CAAS,UAAU,CAAA;AAAA,KACxF;AAAA,EACF;AAEA,EAAA,OAAO,EAAE,IAAA,EAAO,MAAM,QAAA,CAAS,IAAA,IAAc,QAAA,EAAS;AACxD","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n\n // The server doesn't speak QUERY — retry as POST, optionally advertising the\n // original method so override-aware servers can still treat it as a query.\n const fallbackHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n fallbackHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: fallbackHeaders,\n body,\n });\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Statuses worth retrying for an idempotent request. */\nconst RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Details about a failed attempt, passed to retry callbacks. */\nexport interface RetryContext {\n /** 1-based number of the retry about to happen (or being decided). */\n attempt: number;\n /** The response received, when the failure was an HTTP status. */\n response?: Response;\n /** The error thrown, when the failure was a network/abort error. */\n error?: unknown;\n}\n\n/** Controls automatic retries. See {@link QueryOptions.retry}. */\nexport interface RetryOptions {\n /** Maximum number of retries after the initial attempt. */\n retries: number;\n /** Base backoff delay in milliseconds. Default `200`. */\n minDelay?: number;\n /** Maximum backoff delay in milliseconds. Default `10000`. */\n maxDelay?: number;\n /** Exponential backoff multiplier. Default `2`. */\n factor?: number;\n /** Apply random jitter to spread retries out. Default `true`. */\n jitter?: boolean;\n /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */\n respectRetryAfter?: boolean;\n /**\n * Decide whether a failed attempt should be retried. By default, network\n * errors and the statuses 408/425/429/500/502/503/504 are retried, while\n * aborts are not. QUERY is idempotent, so retrying is safe.\n */\n retryOn?: (context: RetryContext) => boolean | Promise<boolean>;\n /** Called before each backoff wait, with the chosen delay. */\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * Automatically retry transient failures. Pass a number for \"retry N times\n * with sensible backoff\", or a {@link RetryOptions} object for full control.\n * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.\n *\n * Retries reuse a buffered body (a string, bytes, or `json`); a streaming\n * body can only be sent once and won't be retried.\n */\n retry?: number | RetryOptions;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\ninterface ResolvedRetry {\n retries: number;\n minDelay: number;\n maxDelay: number;\n factor: number;\n jitter: boolean;\n respectRetryAfter: boolean;\n retryOn: (context: RetryContext) => boolean | Promise<boolean>;\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\nfunction isAbortError(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { name?: string }).name === \"AbortError\"\n );\n}\n\nfunction defaultRetryOn(context: RetryContext): boolean {\n if (context.error !== undefined) return !isAbortError(context.error);\n if (context.response) return RETRYABLE_STATUSES.has(context.response.status);\n return false;\n}\n\nfunction resolveRetry(\n retry: number | RetryOptions | undefined,\n): ResolvedRetry | null {\n if (retry === undefined) return null;\n const opts = typeof retry === \"number\" ? { retries: retry } : retry;\n if (!opts.retries || opts.retries < 1) return null;\n return {\n retries: opts.retries,\n minDelay: opts.minDelay ?? 200,\n maxDelay: opts.maxDelay ?? 10_000,\n factor: opts.factor ?? 2,\n jitter: opts.jitter ?? true,\n respectRetryAfter: opts.respectRetryAfter ?? true,\n retryOn: opts.retryOn ?? defaultRetryOn,\n onRetry: opts.onRetry,\n };\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null;\n const seconds = Number(value);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);\n const date = Date.parse(value);\n if (Number.isNaN(date)) return null;\n return Math.max(0, date - Date.now());\n}\n\nfunction backoffDelay(\n cfg: ResolvedRetry,\n attemptIndex: number,\n response?: Response,\n): number {\n if (cfg.respectRetryAfter && response) {\n const retryAfter = parseRetryAfter(response.headers.get(\"retry-after\"));\n if (retryAfter !== null) return retryAfter;\n }\n const base = Math.min(\n cfg.maxDelay,\n cfg.minDelay * cfg.factor ** attemptIndex,\n );\n return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;\n}\n\nfunction abortError(): Error {\n const error = new Error(\"The operation was aborted.\");\n error.name = \"AbortError\";\n return error;\n}\n\nfunction sleep(ms: number, signal?: AbortSignal | null): Promise<void> {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError());\n return;\n }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer);\n reject(abortError());\n },\n { once: true },\n );\n });\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * retry: 3, // safe: QUERY is idempotent\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n retry: _retry,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n const attempt = async (): Promise<Response> => {\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n\n // The server doesn't speak QUERY — retry as POST, optionally advertising\n // the original method so override-aware servers can still treat it as one.\n const fallbackHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n fallbackHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: fallbackHeaders,\n body,\n });\n };\n\n const retryCfg = resolveRetry(options.retry);\n if (!retryCfg) return attempt();\n\n const signal = init.signal ?? null;\n for (let i = 0; ; i++) {\n let response: Response | undefined;\n let caught: unknown;\n try {\n response = await attempt();\n } catch (error) {\n caught = error;\n }\n\n const isLastAttempt = i >= retryCfg.retries;\n const context: RetryContext = { attempt: i + 1, response, error: caught };\n\n if (caught !== undefined) {\n if (\n isLastAttempt ||\n isAbortError(caught) ||\n !(await retryCfg.retryOn(context))\n ) {\n throw caught;\n }\n } else if (isLastAttempt || !(await retryCfg.retryOn(context))) {\n return response as Response;\n }\n\n const delay = backoffDelay(retryCfg, i, response);\n retryCfg.onRetry?.({ ...context, delay });\n await sleep(delay, signal);\n }\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -14,6 +14,40 @@ declare class QueryError extends Error {
14
14
  name: string;
15
15
  constructor(message: string);
16
16
  }
17
+ /** Details about a failed attempt, passed to retry callbacks. */
18
+ interface RetryContext {
19
+ /** 1-based number of the retry about to happen (or being decided). */
20
+ attempt: number;
21
+ /** The response received, when the failure was an HTTP status. */
22
+ response?: Response;
23
+ /** The error thrown, when the failure was a network/abort error. */
24
+ error?: unknown;
25
+ }
26
+ /** Controls automatic retries. See {@link QueryOptions.retry}. */
27
+ interface RetryOptions {
28
+ /** Maximum number of retries after the initial attempt. */
29
+ retries: number;
30
+ /** Base backoff delay in milliseconds. Default `200`. */
31
+ minDelay?: number;
32
+ /** Maximum backoff delay in milliseconds. Default `10000`. */
33
+ maxDelay?: number;
34
+ /** Exponential backoff multiplier. Default `2`. */
35
+ factor?: number;
36
+ /** Apply random jitter to spread retries out. Default `true`. */
37
+ jitter?: boolean;
38
+ /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */
39
+ respectRetryAfter?: boolean;
40
+ /**
41
+ * Decide whether a failed attempt should be retried. By default, network
42
+ * errors and the statuses 408/425/429/500/502/503/504 are retried, while
43
+ * aborts are not. QUERY is idempotent, so retrying is safe.
44
+ */
45
+ retryOn?: (context: RetryContext) => boolean | Promise<boolean>;
46
+ /** Called before each backoff wait, with the chosen delay. */
47
+ onRetry?: (context: RetryContext & {
48
+ delay: number;
49
+ }) => void;
50
+ }
17
51
  /** Options for {@link query}. Extends `RequestInit` minus the fields we own. */
18
52
  interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
19
53
  /**
@@ -46,6 +80,15 @@ interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
46
80
  * to disable. Defaults to `"X-HTTP-Method-Override"`.
47
81
  */
48
82
  methodOverrideHeader?: string | false;
83
+ /**
84
+ * Automatically retry transient failures. Pass a number for "retry N times
85
+ * with sensible backoff", or a {@link RetryOptions} object for full control.
86
+ * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.
87
+ *
88
+ * Retries reuse a buffered body (a string, bytes, or `json`); a streaming
89
+ * body can only be sent once and won't be retried.
90
+ */
91
+ retry?: number | RetryOptions;
49
92
  /**
50
93
  * A `fetch` implementation to use instead of the global. Handy for testing
51
94
  * or for runtimes that expose `fetch` on a client rather than globally.
@@ -72,6 +115,7 @@ interface QueryJsonResult<T> {
72
115
  * const res = await query("https://api.example.com/search", {
73
116
  * json: { filter: { status: "active" }, sort: "-createdAt" },
74
117
  * accept: "application/json",
118
+ * retry: 3, // safe: QUERY is idempotent
75
119
  * });
76
120
  * ```
77
121
  */
@@ -84,4 +128,4 @@ declare function query(input: string | URL, options?: QueryOptions): Promise<Res
84
128
  */
85
129
  declare function queryJson<T = unknown>(input: string | URL, options?: QueryOptions): Promise<QueryJsonResult<T>>;
86
130
 
87
- export { QueryError, type QueryJsonResult, type QueryOptions, query, queryJson };
131
+ export { QueryError, type QueryJsonResult, type QueryOptions, type RetryContext, type RetryOptions, query, queryJson };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,40 @@ declare class QueryError extends Error {
14
14
  name: string;
15
15
  constructor(message: string);
16
16
  }
17
+ /** Details about a failed attempt, passed to retry callbacks. */
18
+ interface RetryContext {
19
+ /** 1-based number of the retry about to happen (or being decided). */
20
+ attempt: number;
21
+ /** The response received, when the failure was an HTTP status. */
22
+ response?: Response;
23
+ /** The error thrown, when the failure was a network/abort error. */
24
+ error?: unknown;
25
+ }
26
+ /** Controls automatic retries. See {@link QueryOptions.retry}. */
27
+ interface RetryOptions {
28
+ /** Maximum number of retries after the initial attempt. */
29
+ retries: number;
30
+ /** Base backoff delay in milliseconds. Default `200`. */
31
+ minDelay?: number;
32
+ /** Maximum backoff delay in milliseconds. Default `10000`. */
33
+ maxDelay?: number;
34
+ /** Exponential backoff multiplier. Default `2`. */
35
+ factor?: number;
36
+ /** Apply random jitter to spread retries out. Default `true`. */
37
+ jitter?: boolean;
38
+ /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */
39
+ respectRetryAfter?: boolean;
40
+ /**
41
+ * Decide whether a failed attempt should be retried. By default, network
42
+ * errors and the statuses 408/425/429/500/502/503/504 are retried, while
43
+ * aborts are not. QUERY is idempotent, so retrying is safe.
44
+ */
45
+ retryOn?: (context: RetryContext) => boolean | Promise<boolean>;
46
+ /** Called before each backoff wait, with the chosen delay. */
47
+ onRetry?: (context: RetryContext & {
48
+ delay: number;
49
+ }) => void;
50
+ }
17
51
  /** Options for {@link query}. Extends `RequestInit` minus the fields we own. */
18
52
  interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
19
53
  /**
@@ -46,6 +80,15 @@ interface QueryOptions extends Omit<RequestInit, "method" | "body"> {
46
80
  * to disable. Defaults to `"X-HTTP-Method-Override"`.
47
81
  */
48
82
  methodOverrideHeader?: string | false;
83
+ /**
84
+ * Automatically retry transient failures. Pass a number for "retry N times
85
+ * with sensible backoff", or a {@link RetryOptions} object for full control.
86
+ * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.
87
+ *
88
+ * Retries reuse a buffered body (a string, bytes, or `json`); a streaming
89
+ * body can only be sent once and won't be retried.
90
+ */
91
+ retry?: number | RetryOptions;
49
92
  /**
50
93
  * A `fetch` implementation to use instead of the global. Handy for testing
51
94
  * or for runtimes that expose `fetch` on a client rather than globally.
@@ -72,6 +115,7 @@ interface QueryJsonResult<T> {
72
115
  * const res = await query("https://api.example.com/search", {
73
116
  * json: { filter: { status: "active" }, sort: "-createdAt" },
74
117
  * accept: "application/json",
118
+ * retry: 3, // safe: QUERY is idempotent
75
119
  * });
76
120
  * ```
77
121
  */
@@ -84,4 +128,4 @@ declare function query(input: string | URL, options?: QueryOptions): Promise<Res
84
128
  */
85
129
  declare function queryJson<T = unknown>(input: string | URL, options?: QueryOptions): Promise<QueryJsonResult<T>>;
86
130
 
87
- export { QueryError, type QueryJsonResult, type QueryOptions, query, queryJson };
131
+ export { QueryError, type QueryJsonResult, type QueryOptions, type RetryContext, type RetryOptions, query, queryJson };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
2
  var METHOD_UNSUPPORTED_STATUSES = /* @__PURE__ */ new Set([405, 501]);
3
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
3
4
  var DEFAULT_OVERRIDE_HEADER = "X-HTTP-Method-Override";
4
5
  var QueryError = class _QueryError extends Error {
5
6
  name = "QueryError";
@@ -40,6 +41,70 @@ function prepare(options) {
40
41
  }
41
42
  return { headers, body };
42
43
  }
44
+ function isAbortError(error) {
45
+ return typeof error === "object" && error !== null && error.name === "AbortError";
46
+ }
47
+ function defaultRetryOn(context) {
48
+ if (context.error !== void 0) return !isAbortError(context.error);
49
+ if (context.response) return RETRYABLE_STATUSES.has(context.response.status);
50
+ return false;
51
+ }
52
+ function resolveRetry(retry) {
53
+ if (retry === void 0) return null;
54
+ const opts = typeof retry === "number" ? { retries: retry } : retry;
55
+ if (!opts.retries || opts.retries < 1) return null;
56
+ return {
57
+ retries: opts.retries,
58
+ minDelay: opts.minDelay ?? 200,
59
+ maxDelay: opts.maxDelay ?? 1e4,
60
+ factor: opts.factor ?? 2,
61
+ jitter: opts.jitter ?? true,
62
+ respectRetryAfter: opts.respectRetryAfter ?? true,
63
+ retryOn: opts.retryOn ?? defaultRetryOn,
64
+ onRetry: opts.onRetry
65
+ };
66
+ }
67
+ function parseRetryAfter(value) {
68
+ if (!value) return null;
69
+ const seconds = Number(value);
70
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
71
+ const date = Date.parse(value);
72
+ if (Number.isNaN(date)) return null;
73
+ return Math.max(0, date - Date.now());
74
+ }
75
+ function backoffDelay(cfg, attemptIndex, response) {
76
+ if (cfg.respectRetryAfter && response) {
77
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
78
+ if (retryAfter !== null) return retryAfter;
79
+ }
80
+ const base = Math.min(
81
+ cfg.maxDelay,
82
+ cfg.minDelay * cfg.factor ** attemptIndex
83
+ );
84
+ return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;
85
+ }
86
+ function abortError() {
87
+ const error = new Error("The operation was aborted.");
88
+ error.name = "AbortError";
89
+ return error;
90
+ }
91
+ function sleep(ms, signal) {
92
+ return new Promise((resolve, reject) => {
93
+ if (signal?.aborted) {
94
+ reject(abortError());
95
+ return;
96
+ }
97
+ const timer = setTimeout(resolve, ms);
98
+ signal?.addEventListener(
99
+ "abort",
100
+ () => {
101
+ clearTimeout(timer);
102
+ reject(abortError());
103
+ },
104
+ { once: true }
105
+ );
106
+ });
107
+ }
43
108
  async function query(input, options = {}) {
44
109
  const doFetch = resolveFetch(options.fetch);
45
110
  const { headers, body } = prepare(options);
@@ -48,31 +113,58 @@ async function query(input, options = {}) {
48
113
  json: _json,
49
114
  contentType: _contentType,
50
115
  accept: _accept,
116
+ retry: _retry,
51
117
  fallbackToPost = true,
52
118
  methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,
53
119
  headers: _headers,
54
120
  body: _body,
55
121
  ...init
56
122
  } = options;
57
- const response = await doFetch(input, {
58
- ...init,
59
- method: "QUERY",
60
- headers,
61
- body
62
- });
63
- if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
64
- return response;
65
- }
66
- const fallbackHeaders = new Headers(headers);
67
- if (methodOverrideHeader) {
68
- fallbackHeaders.set(methodOverrideHeader, "QUERY");
123
+ const attempt = async () => {
124
+ const response = await doFetch(input, {
125
+ ...init,
126
+ method: "QUERY",
127
+ headers,
128
+ body
129
+ });
130
+ if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {
131
+ return response;
132
+ }
133
+ const fallbackHeaders = new Headers(headers);
134
+ if (methodOverrideHeader) {
135
+ fallbackHeaders.set(methodOverrideHeader, "QUERY");
136
+ }
137
+ return doFetch(input, {
138
+ ...init,
139
+ method: "POST",
140
+ headers: fallbackHeaders,
141
+ body
142
+ });
143
+ };
144
+ const retryCfg = resolveRetry(options.retry);
145
+ if (!retryCfg) return attempt();
146
+ const signal = init.signal ?? null;
147
+ for (let i = 0; ; i++) {
148
+ let response;
149
+ let caught;
150
+ try {
151
+ response = await attempt();
152
+ } catch (error) {
153
+ caught = error;
154
+ }
155
+ const isLastAttempt = i >= retryCfg.retries;
156
+ const context = { attempt: i + 1, response, error: caught };
157
+ if (caught !== void 0) {
158
+ if (isLastAttempt || isAbortError(caught) || !await retryCfg.retryOn(context)) {
159
+ throw caught;
160
+ }
161
+ } else if (isLastAttempt || !await retryCfg.retryOn(context)) {
162
+ return response;
163
+ }
164
+ const delay = backoffDelay(retryCfg, i, response);
165
+ retryCfg.onRetry?.({ ...context, delay });
166
+ await sleep(delay, signal);
69
167
  }
70
- return doFetch(input, {
71
- ...init,
72
- method: "POST",
73
- headers: fallbackHeaders,
74
- body
75
- });
76
168
  }
77
169
  async function queryJson(input, options = {}) {
78
170
  const response = await query(input, {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n\n // The server doesn't speak QUERY — retry as POST, optionally advertising the\n // original method so override-aware servers can still treat it as a query.\n const fallbackHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n fallbackHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: fallbackHeaders,\n body,\n });\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @danmat/query-fetch\n *\n * A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the\n * safe, idempotent request that carries a body like POST but caches like GET.\n *\n * @see https://www.rfc-editor.org/rfc/rfc10008\n */\n\n/** Status codes that signal a server does not understand the QUERY method. */\nconst METHOD_UNSUPPORTED_STATUSES = new Set([405, 501]);\n\n/** Statuses worth retrying for an idempotent request. */\nconst RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);\n\n/** Default header used to tunnel the intended method when falling back to POST. */\nconst DEFAULT_OVERRIDE_HEADER = \"X-HTTP-Method-Override\";\n\n/**\n * Error thrown when a QUERY request cannot be constructed according to the\n * requirements of RFC 10008 (e.g. a body with no `Content-Type`).\n */\nexport class QueryError extends Error {\n override name = \"QueryError\";\n\n constructor(message: string) {\n super(message);\n // Restore prototype chain for transpiled/ES5 targets.\n Object.setPrototypeOf(this, QueryError.prototype);\n }\n}\n\n/** Details about a failed attempt, passed to retry callbacks. */\nexport interface RetryContext {\n /** 1-based number of the retry about to happen (or being decided). */\n attempt: number;\n /** The response received, when the failure was an HTTP status. */\n response?: Response;\n /** The error thrown, when the failure was a network/abort error. */\n error?: unknown;\n}\n\n/** Controls automatic retries. See {@link QueryOptions.retry}. */\nexport interface RetryOptions {\n /** Maximum number of retries after the initial attempt. */\n retries: number;\n /** Base backoff delay in milliseconds. Default `200`. */\n minDelay?: number;\n /** Maximum backoff delay in milliseconds. Default `10000`. */\n maxDelay?: number;\n /** Exponential backoff multiplier. Default `2`. */\n factor?: number;\n /** Apply random jitter to spread retries out. Default `true`. */\n jitter?: boolean;\n /** Honor a `Retry-After` response header (e.g. on 429/503). Default `true`. */\n respectRetryAfter?: boolean;\n /**\n * Decide whether a failed attempt should be retried. By default, network\n * errors and the statuses 408/425/429/500/502/503/504 are retried, while\n * aborts are not. QUERY is idempotent, so retrying is safe.\n */\n retryOn?: (context: RetryContext) => boolean | Promise<boolean>;\n /** Called before each backoff wait, with the chosen delay. */\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\n/** Options for {@link query}. Extends `RequestInit` minus the fields we own. */\nexport interface QueryOptions extends Omit<RequestInit, \"method\" | \"body\"> {\n /**\n * The raw query body. Pair with {@link QueryOptions.contentType}. For JSON\n * payloads prefer {@link QueryOptions.json}, which sets the content type for\n * you.\n */\n body?: BodyInit | null;\n\n /**\n * Convenience: a value serialized to JSON and sent as\n * `application/json`. Ignored if {@link QueryOptions.body} is set.\n */\n json?: unknown;\n\n /**\n * MIME type of the query body. **Required** whenever a body is present —\n * RFC 10008 mandates that servers reject a QUERY with a missing or\n * inconsistent `Content-Type`. Ignored when using {@link QueryOptions.json}.\n */\n contentType?: string;\n\n /** Media type(s) acceptable in the response. Sets the `Accept` header. */\n accept?: string | string[];\n\n /**\n * Retry as `POST` when the server reports it does not support QUERY\n * (HTTP 405 or 501). Defaults to `true`.\n */\n fallbackToPost?: boolean;\n\n /**\n * Header name used to advertise the original method on a POST fallback so\n * that override-aware servers can still route it as a QUERY. Set to `false`\n * to disable. Defaults to `\"X-HTTP-Method-Override\"`.\n */\n methodOverrideHeader?: string | false;\n\n /**\n * Automatically retry transient failures. Pass a number for \"retry N times\n * with sensible backoff\", or a {@link RetryOptions} object for full control.\n * Because QUERY is idempotent, retrying is safe — unlike POST. Off unless set.\n *\n * Retries reuse a buffered body (a string, bytes, or `json`); a streaming\n * body can only be sent once and won't be retried.\n */\n retry?: number | RetryOptions;\n\n /**\n * A `fetch` implementation to use instead of the global. Handy for testing\n * or for runtimes that expose `fetch` on a client rather than globally.\n */\n fetch?: typeof fetch;\n}\n\n/** The result of {@link queryJson}: a parsed body plus the originating response. */\nexport interface QueryJsonResult<T> {\n data: T;\n response: Response;\n}\n\ninterface ResolvedRetry {\n retries: number;\n minDelay: number;\n maxDelay: number;\n factor: number;\n jitter: boolean;\n respectRetryAfter: boolean;\n retryOn: (context: RetryContext) => boolean | Promise<boolean>;\n onRetry?: (context: RetryContext & { delay: number }) => void;\n}\n\nfunction resolveFetch(custom?: typeof fetch): typeof fetch {\n const impl = custom ?? globalThis.fetch;\n if (typeof impl !== \"function\") {\n throw new QueryError(\n \"No fetch implementation found. Pass `fetch` in options or run on a runtime that provides a global fetch.\",\n );\n }\n return impl;\n}\n\n/**\n * Build the request body and headers, enforcing RFC 10008's `Content-Type`\n * requirement. Returns the effective body so it can be reused by a fallback.\n */\nfunction prepare(options: QueryOptions): {\n headers: Headers;\n body: BodyInit | null | undefined;\n} {\n const headers = new Headers(options.headers);\n\n let body: BodyInit | null | undefined = options.body;\n\n if (body == null && options.json !== undefined) {\n body = JSON.stringify(options.json);\n if (!headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n } else if (options.contentType) {\n headers.set(\"content-type\", options.contentType);\n }\n\n const hasBody = body != null;\n if (hasBody && !headers.has(\"content-type\")) {\n throw new QueryError(\n \"A QUERY request with a body must set a Content-Type (RFC 10008). Pass `contentType`, use `json`, or set the header explicitly.\",\n );\n }\n\n if (options.accept !== undefined) {\n const accept = Array.isArray(options.accept)\n ? options.accept.join(\", \")\n : options.accept;\n headers.set(\"accept\", accept);\n }\n\n return { headers, body };\n}\n\nfunction isAbortError(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { name?: string }).name === \"AbortError\"\n );\n}\n\nfunction defaultRetryOn(context: RetryContext): boolean {\n if (context.error !== undefined) return !isAbortError(context.error);\n if (context.response) return RETRYABLE_STATUSES.has(context.response.status);\n return false;\n}\n\nfunction resolveRetry(\n retry: number | RetryOptions | undefined,\n): ResolvedRetry | null {\n if (retry === undefined) return null;\n const opts = typeof retry === \"number\" ? { retries: retry } : retry;\n if (!opts.retries || opts.retries < 1) return null;\n return {\n retries: opts.retries,\n minDelay: opts.minDelay ?? 200,\n maxDelay: opts.maxDelay ?? 10_000,\n factor: opts.factor ?? 2,\n jitter: opts.jitter ?? true,\n respectRetryAfter: opts.respectRetryAfter ?? true,\n retryOn: opts.retryOn ?? defaultRetryOn,\n onRetry: opts.onRetry,\n };\n}\n\nfunction parseRetryAfter(value: string | null): number | null {\n if (!value) return null;\n const seconds = Number(value);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);\n const date = Date.parse(value);\n if (Number.isNaN(date)) return null;\n return Math.max(0, date - Date.now());\n}\n\nfunction backoffDelay(\n cfg: ResolvedRetry,\n attemptIndex: number,\n response?: Response,\n): number {\n if (cfg.respectRetryAfter && response) {\n const retryAfter = parseRetryAfter(response.headers.get(\"retry-after\"));\n if (retryAfter !== null) return retryAfter;\n }\n const base = Math.min(\n cfg.maxDelay,\n cfg.minDelay * cfg.factor ** attemptIndex,\n );\n return cfg.jitter ? base / 2 + Math.random() * (base / 2) : base;\n}\n\nfunction abortError(): Error {\n const error = new Error(\"The operation was aborted.\");\n error.name = \"AbortError\";\n return error;\n}\n\nfunction sleep(ms: number, signal?: AbortSignal | null): Promise<void> {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError());\n return;\n }\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer);\n reject(abortError());\n },\n { once: true },\n );\n });\n}\n\n/**\n * Perform an HTTP QUERY request (RFC 10008).\n *\n * QUERY is safe and idempotent like GET, but carries a request body like POST,\n * making it ideal for large or structured queries that don't fit in a URL.\n *\n * Redirects — including the RFC's `303 See Other` indirect-result pattern — are\n * handled by the underlying `fetch` per the request's `redirect` mode (default\n * `\"follow\"`), so no special handling is needed here.\n *\n * @example\n * ```ts\n * const res = await query(\"https://api.example.com/search\", {\n * json: { filter: { status: \"active\" }, sort: \"-createdAt\" },\n * accept: \"application/json\",\n * retry: 3, // safe: QUERY is idempotent\n * });\n * ```\n */\nexport async function query(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<Response> {\n const doFetch = resolveFetch(options.fetch);\n const { headers, body } = prepare(options);\n\n const {\n fetch: _fetch,\n json: _json,\n contentType: _contentType,\n accept: _accept,\n retry: _retry,\n fallbackToPost = true,\n methodOverrideHeader = DEFAULT_OVERRIDE_HEADER,\n headers: _headers,\n body: _body,\n ...init\n } = options;\n\n const attempt = async (): Promise<Response> => {\n const response = await doFetch(input, {\n ...init,\n method: \"QUERY\",\n headers,\n body,\n });\n\n if (!fallbackToPost || !METHOD_UNSUPPORTED_STATUSES.has(response.status)) {\n return response;\n }\n\n // The server doesn't speak QUERY — retry as POST, optionally advertising\n // the original method so override-aware servers can still treat it as one.\n const fallbackHeaders = new Headers(headers);\n if (methodOverrideHeader) {\n fallbackHeaders.set(methodOverrideHeader, \"QUERY\");\n }\n\n return doFetch(input, {\n ...init,\n method: \"POST\",\n headers: fallbackHeaders,\n body,\n });\n };\n\n const retryCfg = resolveRetry(options.retry);\n if (!retryCfg) return attempt();\n\n const signal = init.signal ?? null;\n for (let i = 0; ; i++) {\n let response: Response | undefined;\n let caught: unknown;\n try {\n response = await attempt();\n } catch (error) {\n caught = error;\n }\n\n const isLastAttempt = i >= retryCfg.retries;\n const context: RetryContext = { attempt: i + 1, response, error: caught };\n\n if (caught !== undefined) {\n if (\n isLastAttempt ||\n isAbortError(caught) ||\n !(await retryCfg.retryOn(context))\n ) {\n throw caught;\n }\n } else if (isLastAttempt || !(await retryCfg.retryOn(context))) {\n return response as Response;\n }\n\n const delay = backoffDelay(retryCfg, i, response);\n retryCfg.onRetry?.({ ...context, delay });\n await sleep(delay, signal);\n }\n}\n\n/**\n * Like {@link query}, but parses the response body as JSON.\n *\n * Throws {@link QueryError} on a non-2xx response so callers don't silently\n * parse an error page.\n */\nexport async function queryJson<T = unknown>(\n input: string | URL,\n options: QueryOptions = {},\n): Promise<QueryJsonResult<T>> {\n const response = await query(input, {\n accept: \"application/json\",\n ...options,\n });\n\n if (!response.ok) {\n throw new QueryError(\n `QUERY ${input.toString()} failed with status ${response.status} ${response.statusText}`,\n );\n }\n\n return { data: (await response.json()) as T, response };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danmat/query-fetch",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the safe, idempotent request with a body. Content-Type enforcement, POST fallback, and Accept negotiation over native fetch.",
5
5
  "keywords": [
6
6
  "http",
@@ -12,7 +12,9 @@
12
12
  "safe-method",
13
13
  "idempotent",
14
14
  "search",
15
- "api-client"
15
+ "api-client",
16
+ "retry",
17
+ "backoff"
16
18
  ],
17
19
  "type": "module",
18
20
  "main": "./dist/index.cjs",