diff --git a/README.md b/README.md index bf99be5d..7e22e024 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ If an object or a class with a `.toJSON()` method is passed to the `body` option `ofetch` utilizes `JSON.stringify()` to convert the passed object. Classes without a `.toJSON()` method have to be converted into a string value in advance before being passed to the `body` option. -For `PUT`, `PATCH`, and `POST` request methods, when a string or object body is set, `ofetch` adds the default `"content-type": "application/json"` and `accept: "application/json"` headers (which you can always override). +For `PUT`, `PATCH`, `POST`, `DELETE`, and `QUERY` request methods, when a string or object body is set, `ofetch` adds the default `"content-type": "application/json"` and `accept: "application/json"` headers (which you can always override). Additionally, `ofetch` supports binary responses with `Buffer`, `ReadableStream`, `Stream`, and [compatible body types](https://developer.mozilla.org/en-US/docs/Web/API/fetch#body). `ofetch` will automatically set the `duplex: "half"` option for streaming support! @@ -114,7 +114,15 @@ await ofetch("/url", { ignoreResponseError: true }); You can specify the amount of retry and delay between them using `retry` and `retryDelay` options and also pass a custom array of codes using `retryStatusCodes` option. -The default for `retry` is `1` retry, except for `POST`, `PUT`, `PATCH`, and `DELETE` methods where `ofetch` does not retry by default to avoid introducing side effects. If you set a custom value for `retry` it will **always retry** for all requests. +The default retry behavior depends on the request method: + +- **Default** (`retry: 1`) — applies to `GET`, `HEAD`, `QUERY`, and other safe methods. +- **No retry by default** (`retry: 0`) — applies to `POST`, `PUT`, `PATCH`, and `DELETE` to avoid introducing side effects. + +You can override the default by setting `retry` explicitly: + +- A **positive number** overrides the method-specific default and will retry for all requests. +- **`false`** or **`0`** disables retries entirely. The default for `retryDelay` is `0` ms. diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..40a1a696 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -3,6 +3,7 @@ import { withBase, withQuery } from "./utils.url.ts"; import { createFetchError } from "./error.ts"; import { isPayloadMethod, + isNonRetryableMethod, isJSONSerializable, detectResponseType, resolveFetchOptions, @@ -51,7 +52,7 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { if (typeof context.options.retry === "number") { retries = context.options.retry; } else { - retries = isPayloadMethod(context.options.method) ? 0 : 1; + retries = isNonRetryableMethod(context.options.method) ? 0 : 1; } const responseCode = (context.response && context.response.status) || 500; diff --git a/src/utils.ts b/src/utils.ts index 3bc0dbf5..0b12ba8b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,12 +8,22 @@ import type { } from "./types.ts"; const payloadMethods = new Set( - Object.freeze(["PATCH", "POST", "PUT", "DELETE"]) + Object.freeze(["PATCH", "POST", "PUT", "DELETE", "QUERY"]) ); export function isPayloadMethod(method = "GET"): boolean { return payloadMethods.has(method.toUpperCase()); } +// Payload methods that are NOT safe/idempotent — these won't retry by default +// to avoid unintended side effects. QUERY is excluded because it's defined as +// safe and idempotent per RFC 10008. +const nonRetryableMethods = new Set( + Object.freeze(["POST", "PUT", "PATCH", "DELETE"]) +); +export function isNonRetryableMethod(method = "GET"): boolean { + return nonRetryableMethods.has(method.toUpperCase()); +} + export function isJSONSerializable(value: any): boolean { if (value === undefined) { return false; diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..13b42c34 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -158,6 +158,21 @@ describe("ofetch", () => { } }); + it("stringifies QUERY body automatically", async () => { + const { body } = await $fetch(getURL("post"), { + method: "QUERY", + body: { query: "{ users { name } }" }, + }); + expect(body).to.deep.eq({ query: "{ users { name } }" }); + + const { headers } = await $fetch(getURL("post"), { + method: "QUERY", + body: { query: "{ users { name } }" }, + }); + expect(headers).to.include({ "content-type": "application/json" }); + expect(headers).to.include({ accept: "application/json" }); + }); + it("does not stringify body when content type != application/json", async () => { const message = '"Hallo von Pascal"'; const { body } = await $fetch(getURL("echo"), {