Tiny, elegant HTTP client built on
fetchfor browser and Node.js
- Zero dependencies — uses native
globalThis.fetch - ~12 KB minified, ESM + CJS
- TypeScript ready — full
.d.tstype definitions - Works everywhere — browser, Node.js 18+, Deno, Bun
npm install @anmetric/netaimport neta from "@anmetric/neta";
// GET with JSON parsing
const data = await neta.get("https://api.example.com/users").json();
// POST with JSON body
const user = await neta
.post("https://api.example.com/users", {
json: { name: "John", email: "john@example.com" },
})
.json();
// Direct callable
const res = await neta("https://api.example.com/users", { method: "get" });Returns a ResponsePromise.
Type: string | URL | Request
Type: object
All fetch options plus:
Type: unknown
JSON body. Automatically stringified and sets Content-Type: application/json.
const data = await neta
.post("https://api.example.com/items", {
json: { title: "New Item" },
})
.json();Type: string | object | URLSearchParams | Array<[string, string]>
Query parameters appended to the URL.
const data = await neta
.get("https://api.example.com/search", {
searchParams: { q: "hello", page: 2 },
})
.json();
// => GET https://api.example.com/search?q=hello&page=2Values set to undefined are filtered out.
Type: string | URL
Prefix prepended to the input URL. Useful for API base paths.
const api = neta.create({ prefix: "https://api.example.com/v2" });
await api.get("users").json();
// => GET https://api.example.com/v2/usersType: string | URL
Base URL for resolving relative inputs using standard URL resolution.
const api = neta.create({ baseUrl: "https://api.example.com/v2/" });
await api.get("users").json();
// => GET https://api.example.com/v2/users
await api.get("../v1/legacy").json();
// => GET https://api.example.com/v1/legacyType: number | false
Default: 10000 (10 seconds)
Request timeout in milliseconds. Set to false to disable.
Type: number | false
Default: false
Total timeout across all retries in milliseconds.
await neta.get("https://api.example.com/slow", {
timeout: 5000,
totalTimeout: 30000,
retry: 5,
});Type: number | object
Default: { limit: 2 }
Retry configuration. Pass a number for simple retry limit, or an object for full control.
// Simple
await neta.get(url, { retry: 3 });
// Full control
await neta.get(url, {
retry: {
limit: 3,
methods: ["get", "put", "head", "delete", "options"],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
afterStatusCodes: [413, 429, 503],
maxRetryAfter: Infinity,
backoffLimit: Infinity,
delay: (attemptCount) => 300 * 2 ** (attemptCount - 1),
jitter: false,
retryOnTimeout: false,
shouldRetry: undefined,
},
});Type: number
Default: 2
Maximum number of retries.
Type: string[]
Default: ['get', 'put', 'head', 'delete', 'options']
HTTP methods eligible for retry.
Type: number[]
Default: [408, 413, 429, 500, 502, 503, 504]
HTTP status codes that trigger a retry.
Type: number[]
Default: [413, 429, 503]
Status codes where the Retry-After header is honored.
Type: number
Default: Infinity
Maximum Retry-After delay (ms) to accept.
Type: number
Default: Infinity
Maximum backoff delay (ms).
Type: (attemptCount: number) => number
Default: (n) => 300 * 2 ** (n - 1)
Function returning delay in ms for each attempt.
Type: boolean | ((delay: number) => number)
Default: false
Add randomness to retry delay to prevent thundering herd.
true— random value between 0 and computed delayfunction— custom jitter function
await neta.get(url, {
retry: {
limit: 5,
delay: (n) => 1000 * 2 ** (n - 1),
jitter: true,
},
});Type: boolean
Default: false
Whether to retry when a request times out.
Type: ({ error, retryCount }) => boolean | undefined | Promise<boolean | undefined>
Custom function to decide whether to retry. Takes precedence over default checks.
- Return
trueto force retry - Return
falseto prevent retry - Return
undefinedto fall through to default behavior
await neta.get(url, {
retry: {
limit: 3,
shouldRetry: ({ error, retryCount }) => {
if (error.response?.status === 401) return false; // Don't retry auth errors
return undefined; // Default behavior for others
},
},
});Type: boolean | ((status: number) => boolean)
Default: true
Throw HTTPError for non-2xx responses. Pass a function for custom logic.
// Never throw
const response = await neta.get(url, { throwHttpErrors: false });
// Only throw on 5xx
const response = await neta.get(url, {
throwHttpErrors: (status) => status >= 500,
});Type: (text: string, context: { request, response }) => unknown
Custom JSON parser. Useful for reviving dates, BigInts, etc.
import LosslessJSON from "lossless-json";
const data = await neta
.get(url, {
parseJson: (text) => LosslessJSON.parse(text),
})
.json();Type: (value: unknown) => string
Custom JSON serializer for the json option.
import LosslessJSON from "lossless-json";
await neta.post(url, {
json: data,
stringifyJson: (value) => LosslessJSON.stringify(value),
});Type: string
Convenience option to set the Authorization: Bearer <token> header. If an Authorization header is already set explicitly, bearerToken will not override it.
const data = await neta
.get("https://api.example.com/me", {
bearerToken: "abc123",
})
.json();
// Sets header: Authorization: Bearer abc123
// Works with create/extend
const api = neta.create({
prefix: "https://api.example.com",
bearerToken: "abc123",
});Type: Record<string, unknown>
Default: {}
Arbitrary data passed through to hooks. Not sent with the request.
await neta.get(url, {
context: { token: "abc123" },
hooks: {
init: [
(options) => {
options.headers = {
...options.headers,
Authorization: `Bearer ${options.context.token}`,
};
},
],
},
});Type: typeof globalThis.fetch
Custom fetch implementation.
import { fetch } from "undici";
const api = neta.create({ fetch });Type: (progress: { percent, transferredBytes, totalBytes }) => void
Download progress callback. Requires ReadableStream support.
await neta.get("https://example.com/large-file", {
onDownloadProgress: ({ percent, transferredBytes, totalBytes }) => {
console.log(
`${Math.round(percent * 100)}% (${transferredBytes}/${totalBytes})`,
);
},
});Type: (progress: { percent, transferredBytes, totalBytes }) => void
Upload progress callback. Requires request streams support (duplex: 'half').
neta.get(input, options?)
neta.post(input, options?)
neta.put(input, options?)
neta.patch(input, options?)
neta.delete(input, options?)
neta.head(input, options?)
neta.options(input, options?)neta methods return a ResponsePromise — a Promise<Response> with body parsing shortcuts:
const json = await neta.get(url).json();
const text = await neta.get(url).text();
const blob = await neta.get(url).blob();
const buffer = await neta.get(url).arrayBuffer();
const form = await neta.get(url).formData();Parse response as JSON. Optionally validate against a Standard Schema:
import { z } from "zod";
const user = await neta.get("/user/1").json(
z.object({
id: z.number(),
name: z.string(),
}),
);
// Throws SchemaValidationError if validation failsCreate a new instance with default options:
const api = neta.create({
prefix: "https://api.example.com",
bearerToken: "my-token",
timeout: 30000,
retry: 3,
});
const data = await api.get("users").json();Alias for neta.create(). Creates a new instance by extending existing defaults:
const api = neta.create({ prefix: "https://api.example.com" });
const authApi = api.extend({ bearerToken: "my-token" });Five hook points for intercepting the request lifecycle.
Type: Array<(options) => void>
Called synchronously before anything else. Can mutate options directly.
neta.create({
hooks: {
init: [
(options) => {
options.headers = {
...options.headers,
"X-Request-Id": crypto.randomUUID(),
};
},
],
},
});Type: Array<({ request, options, retryCount }) => Request | Response | void>
Called before each request. Return a Request to replace it, a Response to short-circuit, or nothing.
neta.create({
hooks: {
beforeRequest: [
({ request }) => {
console.log(`${request.method} ${request.url}`);
},
],
},
});Type: Array<({ request, options, response, retryCount }) => Response | RetryMarker | void>
Called after a successful response. Return a Response to replace it, or neta.retry() to force a retry.
const api = neta.create({
hooks: {
afterResponse: [
async ({ request, response }) => {
if (response.status === 401) {
const token = await refreshToken();
return neta.retry({
request: new Request(request, {
headers: {
...Object.fromEntries(request.headers),
Authorization: `Bearer ${token}`,
},
}),
});
}
},
],
},
});Type: Array<({ request, options, error, retryCount }) => Error | void>
Called before an error is thrown. Return an Error to replace it.
neta.create({
hooks: {
beforeError: [
({ error }) => {
if (error instanceof HTTPError) {
error.message = `API Error: ${error.response.status}`;
}
return error;
},
],
},
});Type: Array<({ request, options, error, retryCount }) => Request | Response | symbol | void>
Called before each retry attempt. Return:
Request— use this request for the retryResponse— skip the retry and use this responsestop— abort the retry loop- nothing — proceed normally
import { stop } from "@anmetric/neta";
neta.create({
hooks: {
beforeRetry: [
({ error, retryCount }) => {
console.log(`Retry #${retryCount}: ${error.message}`);
if (retryCount > 3) return stop;
},
],
},
});Thrown for non-2xx responses (when throwHttpErrors is true).
import { HTTPError } from "@anmetric/neta";
try {
await neta.get("https://api.example.com/missing");
} catch (error) {
if (error instanceof HTTPError) {
console.log(error.response.status); // 404
console.log(error.data); // Auto-parsed response body
console.log(error.request); // The Request object
}
}Thrown when a request exceeds the timeout or totalTimeout.
import { TimeoutError } from "@anmetric/neta";
try {
await neta.get(url, { timeout: 1000 });
} catch (error) {
if (error instanceof TimeoutError) {
console.log("Request timed out:", error.request.url);
}
}Thrown on network failures (DNS, connection refused, etc.).
import { NetworkError } from "@anmetric/neta";
try {
await neta.get("https://nonexistent.invalid");
} catch (error) {
if (error instanceof NetworkError) {
console.log("Network error:", error.message);
console.log("Cause:", error.cause);
}
}Thrown when JSON response fails schema validation.
import { SchemaValidationError } from "@anmetric/neta";
try {
await neta.get(url).json(mySchema);
} catch (error) {
if (error instanceof SchemaValidationError) {
console.log("Validation issues:", error.issues);
}
}neta ships with full TypeScript definitions. Generic type parameters work on .json():
interface User {
id: number;
name: string;
}
const user = await neta.get("https://api.example.com/user/1").json<User>();
// user is typed as Userneta automatically parses these headers during retry:
Retry-AfterRateLimit-ResetX-RateLimit-Retry-AfterX-RateLimit-ResetX-Rate-Limit-Reset
Supports seconds, timestamps, and HTTP dates.
neta uses globalThis.fetch which is available natively in:
- All modern browsers
- Node.js 18+
- Deno
- Bun
No polyfills needed.
MIT