[API reference](https://evolu.dev/docs/api-reference) › [@evolu/common](https://evolu.dev/docs/api-reference/common) › [Http](https://evolu.dev/docs/api-reference/common/Http) › fetch

## Call Signature

```ts
function fetch(
  input: RequestInfo | URL,
  mode: "text",
  init?: Omit<RequestInit, "signal">,
): Task<string, FetchError>;
```

Defined in: [packages/common/src/Http.ts:374](https://github.com/evoluhq/evolu/blob/a18269a1b822c670b507c6a9b73b23eed32551fe/packages/common/src/Http.ts#L374)

Fetches a resource and consumes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) inside the Task, so the
body is read while the request signal is still alive.

The request runs through [run.deps.nativeFetch](https://evolu.dev/docs/api-reference/common/Http/interfaces/NativeFetchDep).
Because native fetch is a default dependency, platforms and tests can replace
it without changing call sites.

With a [FetchMode](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchMode), non-2xx responses return [FetchStatusError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchStatusError)
(except `"headers"`, which reports status as a value) and unreadable bodies
return [FetchBodyError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchBodyError). With a [FetchConsume](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchConsume) callback, native
status semantics apply: HTTP error statuses resolve, and the consumer decides
how to interpret the status and body.

`signal` is not accepted in init because abort is controlled by the current
Run.

Aborting the Run aborts the underlying request, any response that arrives
after abort, and any in-progress body read. Abort is represented as
[AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), not FetchError: `run(fetch(...))` rejects with AbortError,
and `run.abortable(fetch(...))` returns it as an [Err](https://evolu.dev/docs/api-reference/common/Result/interfaces/Err).

Some runtimes reject aborted fetches with their own error instead of
`signal.reason`. This wrapper normalizes abort rejections from native fetch,
built-in body reads, and consumer callbacks back to the Run's AbortError.

`fetch` owns request lifetime and Response containment. It does not transform
requests or interpret app protocols beyond the built-in modes. Use Task
helpers for resilience, app helpers for app conventions, a replacement
[NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) for request-wide behavior (base URLs, auth, logging), and
consumers for response interpretation.

### Composing fetch

Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
[timeout](https://evolu.dev/docs/api-reference/common/Task/functions/timeout), then in [retry](https://evolu.dev/docs/api-reference/common/Task/functions/retry).

```ts
import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
```

App conventions belong in small app-owned helpers. For example, posting JSON
is native `init` plus two conventions worth centralizing — the content-type
header and the stringify:

```ts
import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});
```

Your app's version will grow your conventions — auth, envelopes, error
mapping — which is why it belongs to the app, not to `fetch`.

### Intercepting requests

Request-wide behavior belongs to a replacement [NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) installed
at the composition root. This is the equivalent of interceptors or hooks in
libraries that expose client instances.

```ts

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});
```

### Consuming responses

Built-in modes handle common bodies. Specialized response interpretation
belongs in a consumer. Typed decoders, response envelopes, streaming, and
custom status semantics can be built on top without changing `fetch`.

```ts
import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
```

### Aborting fetch

Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
with [AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), and `run.abortable(fetch(...))` returns it as a
Result error.

```ts

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);
```

## Call Signature

```ts
function fetch(
  input: RequestInfo | URL,
  mode: "json",
  init?: Omit<RequestInit, "signal">,
): Task<unknown, FetchError>;
```

Defined in: [packages/common/src/Http.ts:380](https://github.com/evoluhq/evolu/blob/a18269a1b822c670b507c6a9b73b23eed32551fe/packages/common/src/Http.ts#L380)

Fetches a resource and consumes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) inside the Task, so the
body is read while the request signal is still alive.

The request runs through [run.deps.nativeFetch](https://evolu.dev/docs/api-reference/common/Http/interfaces/NativeFetchDep).
Because native fetch is a default dependency, platforms and tests can replace
it without changing call sites.

With a [FetchMode](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchMode), non-2xx responses return [FetchStatusError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchStatusError)
(except `"headers"`, which reports status as a value) and unreadable bodies
return [FetchBodyError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchBodyError). With a [FetchConsume](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchConsume) callback, native
status semantics apply: HTTP error statuses resolve, and the consumer decides
how to interpret the status and body.

`signal` is not accepted in init because abort is controlled by the current
Run.

Aborting the Run aborts the underlying request, any response that arrives
after abort, and any in-progress body read. Abort is represented as
[AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), not FetchError: `run(fetch(...))` rejects with AbortError,
and `run.abortable(fetch(...))` returns it as an [Err](https://evolu.dev/docs/api-reference/common/Result/interfaces/Err).

Some runtimes reject aborted fetches with their own error instead of
`signal.reason`. This wrapper normalizes abort rejections from native fetch,
built-in body reads, and consumer callbacks back to the Run's AbortError.

`fetch` owns request lifetime and Response containment. It does not transform
requests or interpret app protocols beyond the built-in modes. Use Task
helpers for resilience, app helpers for app conventions, a replacement
[NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) for request-wide behavior (base URLs, auth, logging), and
consumers for response interpretation.

### Composing fetch

Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
[timeout](https://evolu.dev/docs/api-reference/common/Task/functions/timeout), then in [retry](https://evolu.dev/docs/api-reference/common/Task/functions/retry).

```ts
import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
```

App conventions belong in small app-owned helpers. For example, posting JSON
is native `init` plus two conventions worth centralizing — the content-type
header and the stringify:

```ts
import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});
```

Your app's version will grow your conventions — auth, envelopes, error
mapping — which is why it belongs to the app, not to `fetch`.

### Intercepting requests

Request-wide behavior belongs to a replacement [NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) installed
at the composition root. This is the equivalent of interceptors or hooks in
libraries that expose client instances.

```ts

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});
```

### Consuming responses

Built-in modes handle common bodies. Specialized response interpretation
belongs in a consumer. Typed decoders, response envelopes, streaming, and
custom status semantics can be built on top without changing `fetch`.

```ts
import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
```

### Aborting fetch

Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
with [AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), and `run.abortable(fetch(...))` returns it as a
Result error.

```ts

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);
```

## Call Signature

```ts
function fetch(
  input: RequestInfo | URL,
  mode: "bytes",
  init?: Omit<RequestInit, "signal">,
): Task<Uint8Array<ArrayBuffer>, FetchError>;
```

Defined in: [packages/common/src/Http.ts:386](https://github.com/evoluhq/evolu/blob/a18269a1b822c670b507c6a9b73b23eed32551fe/packages/common/src/Http.ts#L386)

Fetches a resource and consumes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) inside the Task, so the
body is read while the request signal is still alive.

The request runs through [run.deps.nativeFetch](https://evolu.dev/docs/api-reference/common/Http/interfaces/NativeFetchDep).
Because native fetch is a default dependency, platforms and tests can replace
it without changing call sites.

With a [FetchMode](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchMode), non-2xx responses return [FetchStatusError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchStatusError)
(except `"headers"`, which reports status as a value) and unreadable bodies
return [FetchBodyError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchBodyError). With a [FetchConsume](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchConsume) callback, native
status semantics apply: HTTP error statuses resolve, and the consumer decides
how to interpret the status and body.

`signal` is not accepted in init because abort is controlled by the current
Run.

Aborting the Run aborts the underlying request, any response that arrives
after abort, and any in-progress body read. Abort is represented as
[AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), not FetchError: `run(fetch(...))` rejects with AbortError,
and `run.abortable(fetch(...))` returns it as an [Err](https://evolu.dev/docs/api-reference/common/Result/interfaces/Err).

Some runtimes reject aborted fetches with their own error instead of
`signal.reason`. This wrapper normalizes abort rejections from native fetch,
built-in body reads, and consumer callbacks back to the Run's AbortError.

`fetch` owns request lifetime and Response containment. It does not transform
requests or interpret app protocols beyond the built-in modes. Use Task
helpers for resilience, app helpers for app conventions, a replacement
[NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) for request-wide behavior (base URLs, auth, logging), and
consumers for response interpretation.

### Composing fetch

Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
[timeout](https://evolu.dev/docs/api-reference/common/Task/functions/timeout), then in [retry](https://evolu.dev/docs/api-reference/common/Task/functions/retry).

```ts
import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
```

App conventions belong in small app-owned helpers. For example, posting JSON
is native `init` plus two conventions worth centralizing — the content-type
header and the stringify:

```ts
import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});
```

Your app's version will grow your conventions — auth, envelopes, error
mapping — which is why it belongs to the app, not to `fetch`.

### Intercepting requests

Request-wide behavior belongs to a replacement [NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) installed
at the composition root. This is the equivalent of interceptors or hooks in
libraries that expose client instances.

```ts

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});
```

### Consuming responses

Built-in modes handle common bodies. Specialized response interpretation
belongs in a consumer. Typed decoders, response envelopes, streaming, and
custom status semantics can be built on top without changing `fetch`.

```ts
import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
```

### Aborting fetch

Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
with [AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), and `run.abortable(fetch(...))` returns it as a
Result error.

```ts

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);
```

## Call Signature

```ts
function fetch(
  input: RequestInfo | URL,
  mode: "headers",
  init?: Omit<RequestInit, "signal">,
): Task<FetchResponse, FetchTransportError>;
```

Defined in: [packages/common/src/Http.ts:392](https://github.com/evoluhq/evolu/blob/a18269a1b822c670b507c6a9b73b23eed32551fe/packages/common/src/Http.ts#L392)

Fetches a resource and consumes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) inside the Task, so the
body is read while the request signal is still alive.

The request runs through [run.deps.nativeFetch](https://evolu.dev/docs/api-reference/common/Http/interfaces/NativeFetchDep).
Because native fetch is a default dependency, platforms and tests can replace
it without changing call sites.

With a [FetchMode](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchMode), non-2xx responses return [FetchStatusError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchStatusError)
(except `"headers"`, which reports status as a value) and unreadable bodies
return [FetchBodyError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchBodyError). With a [FetchConsume](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchConsume) callback, native
status semantics apply: HTTP error statuses resolve, and the consumer decides
how to interpret the status and body.

`signal` is not accepted in init because abort is controlled by the current
Run.

Aborting the Run aborts the underlying request, any response that arrives
after abort, and any in-progress body read. Abort is represented as
[AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), not FetchError: `run(fetch(...))` rejects with AbortError,
and `run.abortable(fetch(...))` returns it as an [Err](https://evolu.dev/docs/api-reference/common/Result/interfaces/Err).

Some runtimes reject aborted fetches with their own error instead of
`signal.reason`. This wrapper normalizes abort rejections from native fetch,
built-in body reads, and consumer callbacks back to the Run's AbortError.

`fetch` owns request lifetime and Response containment. It does not transform
requests or interpret app protocols beyond the built-in modes. Use Task
helpers for resilience, app helpers for app conventions, a replacement
[NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) for request-wide behavior (base URLs, auth, logging), and
consumers for response interpretation.

### Composing fetch

Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
[timeout](https://evolu.dev/docs/api-reference/common/Task/functions/timeout), then in [retry](https://evolu.dev/docs/api-reference/common/Task/functions/retry).

```ts
import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
```

App conventions belong in small app-owned helpers. For example, posting JSON
is native `init` plus two conventions worth centralizing — the content-type
header and the stringify:

```ts
import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});
```

Your app's version will grow your conventions — auth, envelopes, error
mapping — which is why it belongs to the app, not to `fetch`.

### Intercepting requests

Request-wide behavior belongs to a replacement [NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) installed
at the composition root. This is the equivalent of interceptors or hooks in
libraries that expose client instances.

```ts

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});
```

### Consuming responses

Built-in modes handle common bodies. Specialized response interpretation
belongs in a consumer. Typed decoders, response envelopes, streaming, and
custom status semantics can be built on top without changing `fetch`.

```ts
import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
```

### Aborting fetch

Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
with [AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), and `run.abortable(fetch(...))` returns it as a
Result error.

```ts

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);
```

## Call Signature

```ts
function fetch<T, E>(
  input: RequestInfo | URL,
  consume: FetchConsume<T, E>,
  init?: Omit<RequestInit, "signal">,
): Task<T, FetchTransportError | E>;
```

Defined in: [packages/common/src/Http.ts:398](https://github.com/evoluhq/evolu/blob/a18269a1b822c670b507c6a9b73b23eed32551fe/packages/common/src/Http.ts#L398)

Fetches a resource and consumes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) inside the Task, so the
body is read while the request signal is still alive.

The request runs through [run.deps.nativeFetch](https://evolu.dev/docs/api-reference/common/Http/interfaces/NativeFetchDep).
Because native fetch is a default dependency, platforms and tests can replace
it without changing call sites.

With a [FetchMode](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchMode), non-2xx responses return [FetchStatusError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchStatusError)
(except `"headers"`, which reports status as a value) and unreadable bodies
return [FetchBodyError](https://evolu.dev/docs/api-reference/common/Http/interfaces/FetchBodyError). With a [FetchConsume](https://evolu.dev/docs/api-reference/common/Http/type-aliases/FetchConsume) callback, native
status semantics apply: HTTP error statuses resolve, and the consumer decides
how to interpret the status and body.

`signal` is not accepted in init because abort is controlled by the current
Run.

Aborting the Run aborts the underlying request, any response that arrives
after abort, and any in-progress body read. Abort is represented as
[AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), not FetchError: `run(fetch(...))` rejects with AbortError,
and `run.abortable(fetch(...))` returns it as an [Err](https://evolu.dev/docs/api-reference/common/Result/interfaces/Err).

Some runtimes reject aborted fetches with their own error instead of
`signal.reason`. This wrapper normalizes abort rejections from native fetch,
built-in body reads, and consumer callbacks back to the Run's AbortError.

`fetch` owns request lifetime and Response containment. It does not transform
requests or interpret app protocols beyond the built-in modes. Use Task
helpers for resilience, app helpers for app conventions, a replacement
[NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) for request-wide behavior (base URLs, auth, logging), and
consumers for response interpretation.

### Composing fetch

Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
[timeout](https://evolu.dev/docs/api-reference/common/Task/functions/timeout), then in [retry](https://evolu.dev/docs/api-reference/common/Task/functions/retry).

```ts
import {
  createRun,
  exponential,
  fetch,
  retry,
  take,
  timeout,
  type NativeFetch,
} from "@evolu/common";

const fetchWithRetry = (url: string) =>
  retry(timeout(fetch(url, "json"), "30s"), take(2)(exponential("100ms")));

let requestCount = 0;
const nativeFetch: NativeFetch = () => {
  requestCount++;
  return Promise.resolve(
    requestCount === 1
      ? new Response("Try again", { status: 503 })
      : new Response('{"name":"Ada"}'),
  );
};
await using run = createRun({ nativeFetch });

expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
```

App conventions belong in small app-owned helpers. For example, posting JSON
is native `init` plus two conventions worth centralizing — the content-type
header and the stringify:

```ts
import {
  createRun,
  fetch,
  type FetchError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const postJson = (url: string, data: unknown): Task<unknown, FetchError> =>
  fetch(url, "json", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(data),
  });

const nativeFetch: NativeFetch = () =>
  Promise.resolve(new Response('{"id":"user-1"}'));
await using run = createRun({ nativeFetch });

expectOk(await run(postJson("/api/users", { name: "Ada" })), {
  id: "user-1",
});
```

Your app's version will grow your conventions — auth, envelopes, error
mapping — which is why it belongs to the app, not to `fetch`.

### Intercepting requests

Request-wide behavior belongs to a replacement [NativeFetch](https://evolu.dev/docs/api-reference/common/Http/type-aliases/NativeFetch) installed
at the composition root. This is the equivalent of interceptors or hooks in
libraries that expose client instances.

```ts

const token = "secret-token";
const baseUrl = "https://api.example.com/v1/";
let interceptedRequest: Request | undefined;
const baseFetch: NativeFetch = (input, init) => {
  interceptedRequest = new Request(input, init);
  return Promise.resolve(new Response("ok"));
};

const nativeFetch: NativeFetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("authorization", `Bearer ${token}`);

  // Only string inputs are resolved against the base URL; URL and Request
  // inputs are passed through unchanged.
  const url = typeof input === "string" ? new URL(input, baseUrl) : input;
  return baseFetch(url, { ...init, headers });
};

await using run = createRun({ nativeFetch });
expectOk(await run(fetch("users", "text")), "ok");
expect({
  url: interceptedRequest?.url,
  authorization: interceptedRequest?.headers.get("authorization"),
}).toEqual({
  url: "https://api.example.com/v1/users",
  authorization: "Bearer secret-token",
});
```

### Consuming responses

Built-in modes handle common bodies. Specialized response interpretation
belongs in a consumer. Typed decoders, response envelopes, streaming, and
custom status semantics can be built on top without changing `fetch`.

```ts
import {
  createRun,
  fetch,
  ok,
  type FetchTransportError,
  type NativeFetch,
  type Task,
} from "@evolu/common";

const nativeFetch: NativeFetch = (input) =>
  Promise.resolve(
    String(input).endsWith("/metadata")
      ? new Response(null, {
          status: 204,
          headers: { "cache-control": "max-age=60" },
        })
      : new Response('{"name":"Ada"}'),
  );
await using run = createRun({ nativeFetch });

const user = await run(fetch("/api/user", "json"));
const metadata = fetch("/api/user/metadata", (response) =>
  ok({
    status: response.status,
    cache: response.headers.get("cache-control"),
  }),
);
expectTypeOf(metadata).toEqualTypeOf<
  Task<{ status: number; cache: string | null }, FetchTransportError>
>();
expectOk(user, { name: "Ada" });
expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
```

### Aborting fetch

Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
with [AbortError](https://evolu.dev/docs/api-reference/common/Task/variables/AbortError), and `run.abortable(fetch(...))` returns it as a
Result error.

```ts

const nativeFetch: NativeFetch = (_input, init) =>
  new Promise<Response>((_resolve, reject) => {
    const signal = init?.signal;
    if (!signal) throw new Error("Missing signal");
    signal.addEventListener("abort", () => reject(signal.reason), {
      once: true,
    });
  });
await using run = createRun({ nativeFetch });

const fiber = run.abortable(fetch("/api/user", "json"));
fiber.abort();
const result = await fiber;

expect(!result.ok && AbortError.is(result.error)).toBe(true);
```