Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { withBase, withQuery } from "./utils.url.ts";
import { createFetchError } from "./error.ts";
import {
isPayloadMethod,
isNonRetryableMethod,
isJSONSerializable,
detectResponseType,
resolveFetchOptions,
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
Comment thread
jonty-comp marked this conversation as resolved.
export function isPayloadMethod(method = "GET"): boolean {
return payloadMethods.has(method.toUpperCase());
Comment thread
jonty-comp marked this conversation as resolved.
}

// 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;
Expand Down
15 changes: 15 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), {
Expand Down