get-it
Generic HTTP request library for node.js (>= 22.12), browsers, and edge runtimes. Built on fetch().
Features
- Promise-based API with full TypeScript support
- Automatic JSON serialization/deserialization
- Base URL and default headers
- HTTP error throwing (on by default)
- Timeout via
AbortSignal.timeout() - Cancellation via standard
AbortController - Proxy support in Node.js, Bun, and Deno (reads
HTTP_PROXY/HTTPS_PROXYfrom environment) - Middleware system for retry, debug logging, and custom logic
- Injectable
fetchfor testing and custom transports - Built-in mock fetch with request matching, recording, and vitest matchers
- Works in Node.js, browsers, Deno, Bun, and edge runtimes
Installation
npm install get-it
Usage
import {createRequester} from 'get-it'
const request = createRequester({
base: 'https://api.example.com',
headers: {Authorization: 'Bearer ...'},
})
// Simple GET
const res = await request('/users')
console.log(res.json())
// POST with JSON body (auto-serialized)
const res = await request({
url: '/users',
method: 'POST',
body: {name: 'Espen'},
as: 'json',
})
console.log(res.body) // parsed JSON
Response
The response object depends on the as option:
as value |
body type |
Buffered? |
|---|---|---|
| (omitted) | Uint8Array + .json(), .text(), .bytes() |
yes |
'json' |
unknown (or generic T) |
yes |
'text' |
string |
yes |
'stream' |
ReadableStream<Uint8Array> |
no |
// Default — buffered with convenience methods
const res = await request('/data')
res.status // number
res.statusText // string
res.headers // Headers
res.body // Uint8Array
res.json() // parse as JSON (synchronous)
res.text() // decode as string (synchronous)
// Typed JSON
const res = await request<User[]>({url: '/users', as: 'json'})
res.body // User[]
// Streaming
const res = await request({url: '/large-file', as: 'stream'})
res.body // ReadableStream<Uint8Array>
Options
Instance options (createRequester)
| Option | Type | Default | Description |
|---|---|---|---|
base |
string |
— | Base URL prepended to relative paths |
headers |
FetchHeaders |
— | Default headers for all requests |
httpErrors |
boolean |
true |
Throw HttpError on status >= 400 |
timeout |
number | false | TimeoutOptions |
— | Timeout in ms, or {total, headers} — see Timeouts |
fetch |
FetchFunction |
globalThis.fetch |
Custom fetch implementation |
middleware |
Array |
[] |
Transform and wrapping middleware |
Per-request options
| Option | Type | Description |
|---|---|---|
url |
string |
Request URL (required) |
method |
string |
HTTP method |
body |
unknown |
Request body (objects auto-serialized as JSON) |
headers |
FetchHeaders |
Merged with instance headers |
query |
Record<string, string | number | boolean | undefined> |
URL query parameters |
as |
'json' | 'text' | 'stream' |
Response body type |
signal |
AbortSignal |
Cancellation signal |
httpErrors |
boolean |
Override instance setting |
timeout |
number | false | TimeoutOptions |
Override instance timeout (replaces it wholesale) |
fetch |
FetchFunction |
Override instance fetch |
redirect |
'error' | 'follow' | 'manual' |
Redirect strategy ('manual' is opaque in browsers - see note) |
credentials |
'include' | 'omit' | 'same-origin' |
Credentials mode (browser-only) |
Note on
redirect: 'manual': In browsers this yields an opaque-redirect response (status0, empty headers) per the Fetch spec, so the 3xx status and headers (e.g.location) are unreadable. Reading them neither throws nor warns -headers.get()returnsnulland iteration is empty - so detect the case viastatus === 0. Non-browser runtimes (Node.js, Bun, Deno, edge runtimes, workers) return the real 3xx response, so its status and headers are readable.
Timeouts
timeout accepts a total deadline in milliseconds, false to disable, or a structured object:
const request = createRequester({
timeout: {
total: 120_000, // total deadline, request start through body (default 120 000)
headers: 15_000, // max time to receive response headers, per attempt (disabled by default)
},
})
total— the existing deadline. Covers everything, including the body stream inas: 'stream'mode. Rejects with aTimeoutErrorDOMException, which theretry()middleware never retries. When combined with the retry() middleware, the deadline applies per attempt - each retry gets a fresh total timer.headers— time to receive response headers for one fetch attempt. Does not cover body download. Rejects with get-it'sTimeoutError(code: 'ETIMEDOUT',phase: 'headers'), which the defaultretry()middleware retries on GET/HEAD. Because the timer lives inside the middleware chain, each retry attempt gets a fresh timer — no middleware ordering requirements.
For long-running streaming downloads, disable the total deadline and keep a headers timeout:
import {createRequester} from 'get-it'
import {retry} from 'get-it/middleware'
const request = createRequester({
middleware: [retry()],
timeout: {headers: 15_000, total: false},
})
const res = await request({url: 'https://example.com/big-file', as: 'stream'})
Error handling
import {HttpError} from 'get-it'
try {
await request('/not-found')
} catch (err) {
if (err instanceof HttpError) {
console.log(err.status) // 404
console.log(err.response) // full response object
}
}
// Disable for a single request
const res = await request({url: '/maybe-404', httpErrors: false})
Cancellation
const controller = new AbortController()
const promise = request({url: '/slow', signal: controller.signal})
controller.abort()
Timeout and user-provided signals are combined automatically with AbortSignal.any().
Middleware
Two types of middleware, passed in the middleware array:
Transform middleware (object) — flat pipeline, invisible in stack traces:
const addHeader: TransformMiddleware = {
beforeRequest(options) {
return {
...options,
headers: {...options.headers, 'x-custom': 'value'},
}
},
}
Wrapping middleware (function) — wraps the fetch call, appears in stack traces:
const logger: WrappingMiddleware = async (options, next) => {
console.log('fetching', options.url)
const response = await next(options)
console.log('done', response.status)
return response
}
Built-in middleware
import {retry, debug} from 'get-it/middleware'
const request = createRequester({
middleware: [retry({maxRetries: 3}), debug({log: console.log, verbose: true})],
})
Runtime proxy support
In Node.js and Bun, createRequester automatically uses an undici-based fetch that reads proxy configuration from environment variables.
In Deno, createRequester uses Deno's built-in fetch, which also reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY.
For custom proxy or connection pool settings:
import {createRequester} from 'get-it'
import {createNodeFetch} from 'get-it/node'
const request = createRequester({
fetch: createNodeFetch({
proxy: 'http://proxy:8080',
connections: 30,
allowH2: true,
}),
})
For explicit per-client proxy settings, custom CA certificates, or HTTP/2 settings in Deno, create a Deno HttpClient and inject a custom fetch:
const client = Deno.createHttpClient({
proxy: {url: 'http://proxy:8080'},
http2: true,
})
const request = createRequester({
fetch: (url, init) => fetch(url, {...init, client}),
})
Testing
get-it/mock provides a mock fetch for testing code that uses get-it. No network, no global patching — just inject mock.fetch where you'd normally pass fetch.
import {createRequester} from 'get-it'
import {createMockFetch, objectContaining} from 'get-it/mock'
const mock = createMockFetch()
const request = createRequester({fetch: mock.fetch, base: 'https://api.example.com'})
// Register handlers — responses are one-shot by default
mock.on('GET', '/api/docs', {query: {limit: '10'}}).respond({status: 200, body: {results: []}})
mock
.on('POST', '/api/docs', {body: objectContaining({_type: 'post'})})
.respond({status: 201, body: {id: 'abc'}})
const res = await request({url: '/api/docs', query: {limit: 10}, as: 'json'})
// res.body → {results: []}
Request matching
Requests are matched strictly by default — method, URL path, query parameters, and body must all match exactly. For looser matching, use the built-in matchers:
import {
objectContaining,
arrayContaining,
stringMatching,
anyValue,
queryContaining,
} from 'get-it/mock'
mock
.on('POST', '/api/docs', {
body: objectContaining({_type: 'post', title: stringMatching(/^Hello/)}),
})
.respond({status: 201, body: {id: 'abc'}})
objectContaining(subset)- matches an object that contains at least the given keys/valuesarrayContaining(items)- matches an array that contains at least the given itemsstringMatching(pattern)- matches a string against a regex or substringanyValue()- matches any valuequeryContaining(subset)- matches a query object partially, coercing expected number/boolean values to strings (since query params are always strings)
These implement the asymmetricMatch protocol, so vitest's expect.objectContaining() and friends work too.
URL matching supports exact strings, glob patterns (/api/docs/*/revisions), and function predicates:
mock.on('GET', '/api/docs/*/revisions').respond({status: 200, body: []})
mock.on('GET', (url) => url.startsWith('/api/')).respond({status: 200, body: 'ok'})
Response sequences
Chain .respond() for ordered sequences (useful for testing retries):
mock
.on('GET', '/api/flaky')
.respond({status: 500, body: 'error'})
.respond({status: 200, body: 'ok'})
// First call → 500, second call → 200, third call → throws
Use .respondPersist() for handlers that should match indefinitely.
Delayed responses
Use the delay option to simulate server response time (in milliseconds):
mock.on('GET', '/slow').respond({status: 200, body: {ok: true}, delay: 100})
The request is treated as sent immediately; the response resolves after delay ms. If the request is aborted before the delay elapses - via an AbortController signal or a get-it timeout - it rejects with the signal's reason and the pending timer is cleared.
Streaming response bodies
streamBody() declares a body delivered in chunks, with optional pauses, a
stall, or a mid-download error. A fresh stream is built per consumption, so it
works with respondPersist. delay still controls time-to-headers; the
script controls the body after that.
import {createMockFetch, streamBody, streamDelay, streamStall} from 'get-it/mock'
const mock = createMockFetch()
mock.on('GET', '/backup').respond({
status: 200,
body: streamBody('partial', streamDelay(1000), 'done'),
})
// A download that stalls forever after the first chunk:
const stalled = streamBody('partial', streamStall())
mock.on('GET', '/stuck').respond({body: stalled})
// ...after the consumer cancels the body (e.g. its read timeout fired):
expect(stalled).toHaveBeenCancelled() // matcher from 'get-it/vitest'
streamDelay(ms)pauses between chunks;streamStall()never closes the body (ends only via consumer cancel or signal abort);streamError(err)errors the stream mid-download.- Aborting the request signal errors the body with the abort reason, matching real fetch behavior.
- Buffered reads (no
as, ortext()/arrayBuffer()) drain the script with the same timing, so total-deadline timeout behavior is testable too. - The
streamBody()return value is the observability handle:cancelCountandlastCancelReason, or assert withexpect(body).toHaveBeenCancelled().
Scoped mocks
When your code talks to multiple hosts, use mock.scope() to assert the right requests go to the right place:
const mock = createMockFetch()
const api = mock.scope('https://abc123.api.sanity.io')
const cdn = mock.scope('https://abc123.apicdn.sanity.io')
api.on('POST', '/v1/data/mutate/prod').respond({status: 200, body: {transactionId: 'tx1'}})
cdn.on('GET', '/v1/data/query/prod').respond({status: 200, body: {result: []}})
const request = createRequester({fetch: mock.fetch})
await request({url: 'https://abc123.apicdn.sanity.io/v1/data/query/prod', as: 'json'})
// Each scope only sees its own traffic
cdn.getRequests() // 1 request
api.getRequests() // 0 requests
mock.getRequests() // all requests
You can also pass full URLs directly without scopes for one-offs:
mock.on('GET', 'https://api.sanity.io/v1/projects').respond({status: 200, body: []})
Handlers registered without an origin (plain paths like /api/docs) match requests to any host.
Unmatched requests
Any request that doesn't match a registered handler throws a MockFetchError with the closest matching handler and a field-level diff:
MockFetchError: No mock matched POST /api/documents?limit=10
Closest mock:
POST /api/documents?limit=20
Differences:
query.limit: expected "20", received "10"
Request inspection
Every request is recorded for later inspection:
await request({url: '/api/docs', body: {title: 'Hello'}, method: 'POST'})
const reqs = mock.getRequests()
reqs[0].method // 'POST'
reqs[0].url // '/api/docs'
reqs[0].body // {title: 'Hello'}
reqs[0].headers // Headers
Lifecycle
afterEach(() => {
mock.assertAllConsumed() // fail if registered responses weren't used
mock.clear()
})
Vitest matchers
get-it/vitest adds custom matchers to vitest's expect:
// In your test setup file or vitest.config setupFiles
import 'get-it/vitest'
// Assert requests were made
expect(mock).toHaveReceivedRequest('POST', '/api/docs', {
body: objectContaining({_type: 'post'}),
})
expect(mock).toHaveReceivedRequestTimes('GET', '/api/docs', 2)
expect(mock).toHaveConsumedAllMocks()
// Assert a streamBody() response body was cancelled by the consumer
expect(scriptedBody).toHaveBeenCancelled()
// Assert on individual recorded requests
const req = mock.getRequests()[0]
expect(req).toHaveHeader('authorization', 'Bearer token123')
expect(req).toHaveBody(objectContaining({_type: 'post'}))
expect(req).toHaveQuery({limit: '10'})
expect(req).toHaveMethod('POST')
expect(req).toHaveUrl('/api/docs')
Entry points
| Import | Purpose |
|---|---|
get-it |
Core (auto-selects Node variant via conditional exports) |
get-it/middleware |
retry, debug, isRetryableRequest, getRetryDelay |
get-it/node |
createNodeFetch() for custom undici dispatcher config |
get-it/mock |
createMockFetch() and matchers for testing |
get-it/vitest |
Custom vitest matchers for mock assertions |
Migrating from v8
See docs/MIGRATION-v9.md for a comprehensive migration guide. It doubles as a playbook for AI agents: point yours at the guide and ask it to migrate the codebase.
License
MIT-licensed. See LICENSE.
Release new version
Run the "CI & Release" workflow. Make sure to select the main branch and check "Release new version".
Semantic release will only release on configured branches, so it is safe to run release on any branch.