A lightweight, extensible HTTP client built on top of the native fetch API.
Provides retry logic, timeouts, abort handling, and a plugin system — while keeping a simple and predictable interface.
- Native
fetchwrapper (no heavy dependencies) - Built-in retry mechanism with exponential backoff
- Timeout support via
AbortController - AbortSignal merging
- Plugin system (request / response / error hooks)
- Typed responses via generics
- Simple and flexible API
- Query parameter support
npm install bestfetch-gimport { BestFetch } from "bestfetch-g";
const api = new BestFetch({
baseURL: "https://api.example.com"
});
const users = await api.get<User[]>("/users");You can strongly type the response using generics:
type User = {
id: number;
name: string;
};
const users = await api.get<User[]>("/users");
// `users` is auto typed as `User[]`For more control:
const result = await api.get<{ data: User[] }>("/users", {
callbacks: {
onSuccess: (data) => data.data
}
});await api.get("/users");await api.post("/users", {
name: "John"
});await api.put("/users/1", {
name: "Updated"
});await api.patch("/users/1", {
name: "Patched"
});await api.delete("/users/1");await api.get("/users", {
query: {
limit: 10,
offset: 20,
search: "john doe",
tag: ["admin", "active"]
}
});Generated URL:
/users?limit=10&offset=20&search=john%20doe
await api.get("/users", {
headers: {
Authorization: "Bearer token"
}
});JSON is automatically serialized:
await api.post("/users", {
name: "John",
age: 25
});await api.get("/users", {
timeout: 2000 // ms
});const controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
await api.get("/users", {
signal: controller.signal
});Supports merging multiple signals internally.
const api = new BestFetch("https://api.example.com", {
retry: {
retries: 5,
baseDelay: 500
}
});Per-request override:
await api.get("/users", {
retry: {
retries: 2
}
});await api.get("/users", {
callbacks: {
onSuccess: (data) => data.users
}
});Return false to stop retrying. Any other return value leaves the default retry rules in charge.
await api.get("/users", {
callbacks: {
onError: (response, isLastAttempt) => {
if (response.status === 404) return false;
}
}
});After all attempts fail, the client throws HttpError:
import { BestFetch, HttpError } from "bestfetch-g";
try {
await api.get("/missing");
} catch (error) {
if (error instanceof HttpError) {
console.log(error.status, error.body);
}
}Invalid JSON on a successful status code throws ParseError (with response and cause).
| Situation | Error type |
|---|---|
| HTTP 4xx/5xx | HttpError |
| Invalid JSON body | ParseError |
| Network / abort | native Error / DOMException |
Same contract as onError: false stops retrying.
await api.get("/users", {
callbacks: {
onNetworkError: (error, isLastAttempt) => {
console.error(error, isLastAttempt);
}
}
});For tests or a custom stack, pass your own Transport:
import { BestFetch, type Transport } from "bestfetch-g";
const transport: Transport = {
async send(request) {
return fetch(request);
}
};
const api = new BestFetch({
baseURL: "https://api.example.com",
transport
});Plugins allow you to hook into request/response lifecycle.
Remove a plugin with api.eject(plugin).
import { BestFetch, createAuthPlugin } from "bestfetch-g";
api.use(createAuthPlugin(() => localStorage.getItem("jwt")));Or inline:
api.use({
onRequest(request) {
const token = localStorage.getItem("jwt");
if (!token) return request;
const headers = new Headers(request.headers);
headers.set("Authorization", `Bearer ${token}`);
return new Request(request, { headers });
}
});api.use({
onRequest(request) {
console.log("Request:", request.url);
return request;
},
onResponse(response) {
console.log("Response:", response.status);
return response;
}
});api.use({
onError(error) {
console.error("Global fetch error:", error);
return error;
}
});By default, responses are parsed as JSON.
If the response is not JSON, you can override behavior:
await api.get<string>("/text-endpoint", {
callbacks: {
onSuccess: async (_, response) => {
return await response.text();
}
}
});MIT