HTTP Client
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and is current as of 2026-10-02. It documents @nestjs/http-client, which is pre-1.0 at the time of writing (the npm version is 0.0.1, requires Node.js 20.19 or later, and declares @nestjs/common and @nestjs/core ^11.0.0 || ^12.0.0 as its only peers besides reflect-metadata), so option names and defaults can still change between releases; pin the version and read its release notes before upgrading. The examples use a project generated by the Nest 12 CLI (ESM layout, Vitest, supertest) and a local Node.js http server that plays the part of the upstream service; the outputs shown are the ones that setup produces.
Most backends call other services over HTTP: a payment provider, a search API, another microservice. In the running Bookshelf scenario, the application asks an external catalog service for book metadata, posts reviews to it and proxies cover images from it. NestJS 12’s documentation teaches @nestjs/http-client for this: a client built on the fetch API that ships with Node.js, configured as a Nest module and injected like any provider. It adds what fetch leaves out (base URLs, default headers, JSON bodies, path and query parameters, typed responses, timeouts, retries, interceptors and named clients) and has no runtime dependencies besides @nestjs/common and @nestjs/core. Every method returns a Promise, so you await requests and handle failures with try/catch.
It replaces the Axios-based HttpModule of @nestjs/axios, which older tutorials teach. That package still exists and still works (see Migrating from @nestjs/axios), but new code should start with @nestjs/http-client.
Source: NestJS documentation: HTTP client.
Installation and getting started
npm i --save @nestjs/http-client
Import HttpClientModule into the module that makes the requests and register a client with register(). Then inject the HttpClient class:
import { Injectable, Module } from '@nestjs/common';
import { HttpClient, HttpClientModule } from '@nestjs/http-client';
@Injectable()
export class CatsService {
constructor(private readonly http: HttpClient) {}
async findAll(): Promise<{ name: string }[]> {
const { data } = await this.http.get<{ name: string }[]>('/cats');
return data;
}
}
@Module({
imports: [
HttpClientModule.register({
baseUrl: 'https://api.example.com/v1',
timeout: '5s',
}),
],
providers: [CatsService],
})
export class CatsModule {}
The request goes to https://api.example.com/v1/cats. A relative URL is appended to baseUrl with exactly one / between them, so the /v1 prefix is kept (unlike new URL('/cats', base), which would drop it). A client without a baseUrl requires absolute URLs. As with other register() methods (see dynamic modules), each call creates a client for the module that imports it; set isGlobal: true to make one injectable everywhere.
Named clients
An application usually talks to several upstream services, each with its own base URL, credentials and timeouts. Give each client a name and inject it with the @InjectHttpClient() decorator. A client registered without a name is the default client, injected as HttpClient. The getHttpClientToken(name) function returns a client’s injection token, which is useful in tests and custom providers.
@Module({
imports: [HttpClientModule.register({ name: 'catalog', baseUrl: 'https://catalog.example.com' })],
providers: [CatalogService],
})
export class CatalogModule {}
@Injectable()
export class CatalogService {
constructor(@InjectHttpClient('catalog') private readonly catalog: HttpClient) {}
}
Application-wide defaults
Settings that every client shares, such as a user-agent header, a timeout, a retry policy or a logging interceptor, go into HttpClientModule.forRoot(). Import it once, in the root module; it is always global. It registers no client itself and takes no baseUrl.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpClientModule } from '@nestjs/http-client';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
HttpClientModule.forRoot({
headers: { 'user-agent': 'bookshelf/1.0' },
timeout: '10s',
interceptors: [HttpLoggingInterceptor],
}),
CatalogModule,
],
controllers: [CatalogController],
providers: [HttpLoggingInterceptor],
})
export class AppModule {}
Each client’s own settings win over these defaults, with three exceptions: headers are merged (set a header to null in a client to remove a default), retry settings are merged field by field, and the forRoot() interceptors run before the client’s own. For example, a request made through the catalog client carries the user-agent from forRoot() together with the authorization and x-api-key headers added by interceptors.
Async configuration
Build a client’s options from other providers, such as ConfigService, with registerAsync(). The base URL and the token come from configuration, never from literals in source (see configuration).
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HttpClientModule, type Duration } from '@nestjs/http-client';
@Module({
imports: [
ConfigModule,
HttpClientModule.registerAsync({
name: 'catalog',
imports: [ConfigModule],
inject: [ConfigService],
interceptors: [CatalogAuthInterceptor],
useFactory: (config: ConfigService) => ({
baseUrl: config.getOrThrow<string>('CATALOG_URL'),
timeout: config.get<Duration>('CATALOG_TIMEOUT', '5s'),
retry: {
attempts: 4,
backoff: { delay: '50ms', factor: 2, maxDelay: '1s', jitter: 'full' },
},
}),
}),
],
providers: [CatalogTokenService, CatalogAuthInterceptor, CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}
The factory can be async. It returns the same options as register() (typed HttpClientFactoryOptions) except for name, isGlobal, imports and interceptor classes: Nest needs those to build the module before the factory runs, so they sit next to useFactory, as interceptors does above. A factory that returns one of them stops the application at startup. The factory may itself return interceptors, but only functions and instances.
Instead of a factory you can pass a class that implements HttpClientOptionsFactory; Nest instantiates it inside the client’s module and calls its createHttpClientOptions() method. useExisting reuses a provider that already exists (with the module that exports it in imports). forRootAsync() works the same way, with HttpClientModuleOptionsFactory and createHttpClientModuleOptions().
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { HttpClientFactoryOptions, HttpClientOptionsFactory } from '@nestjs/http-client';
@Injectable()
export class CatalogClientConfig implements HttpClientOptionsFactory {
constructor(private readonly config: ConfigService) {}
createHttpClientOptions(): HttpClientFactoryOptions {
return {
baseUrl: this.config.getOrThrow<string>('CATALOG_URL'),
timeout: '2s',
};
}
}
// HttpClientModule.registerAsync({ imports: [ConfigModule], useClass: CatalogClientConfig })
Making requests
A client has a method for each verb: get(), post(), put(), patch(), delete(), head() and options(). Each takes a URL and an optional options object and resolves to an HttpResponse; request() takes the verb as the method option.
import { Injectable } from '@nestjs/common';
import { HttpClient, InjectHttpClient } from '@nestjs/http-client';
import type { BookInfo, CreateReviewDto, Review } from './catalog.types.js';
@Injectable()
export class CatalogService {
constructor(@InjectHttpClient('catalog') private readonly catalog: HttpClient) {}
async findByAuthor(author: string): Promise<BookInfo[]> {
const { data } = await this.catalog.get<BookInfo[]>('/authors/:author/books', {
params: { author },
query: { sort: 'title', tag: ['fiction', 'classic'] },
});
return data;
}
async createReview(review: CreateReviewDto): Promise<Review> {
const { data } = await this.catalog.post<Review>('/reviews', { json: review });
return data;
}
}
For findByAuthor('frank herbert') the upstream server receives:
GET /authors/frank%20herbert/books?sort=title&tag=fiction&tag=classic
The options mirror what a Nest controller reads from a request:
-
paramsfills the:namesegments of the path, URI-encoded. A segment without a value throws, even whenparamsis omitted, and so does a key that matches no segment or a value that would move the request to another endpoint (an empty string,.or..). -
queryis an object or aURLSearchParams, appended to any query already in the URL. Arrays repeat the key,Datevalues become ISO strings, andnullandundefinedvalues are skipped. -
jsonis serialized withJSON.stringify()and setscontent-type: application/jsonunless you set one.bodyis passed tofetchas is (string,Buffer,FormData,URLSearchParams,Blob, a stream or an async iterable). Pass one or the other, not both; aGETorHEADwith a body throws before anything is sent. -
headersis merged over the client’s headers; anullvalue removes one. -
timeout,retry,signal,throwOnHttpError,redirectanddispatcheroverride the client’s settings for one request. -
contextis a free-form object that interceptors can read.
The type argument (BookInfo[]) only describes data; nothing checks it at runtime, so validate responses from APIs you do not control (pipes and validation covers schema validation). The responseType option controls how the body is read:
responseType |
data |
|---|---|
|
Parsed JSON for JSON content types (including |
|
Parsed JSON, whatever the content type. |
|
A string. |
|
An |
|
A Node.js |
|
The web |
The type of data follows responseType. The response object has status, statusText, ok, headers (a web Headers, so headers.get('etag')), data, url (the final URL after redirects) and request (the request sent, after interceptors ran). A successful response whose body is not valid JSON although JSON was expected rejects with an HttpParseError.
Timeouts and cancellation
fetch has no timeout of its own, apart from undici’s five-minute limits. Set timeout on a client or a request, in milliseconds or as a duration string such as '500ms', '5s' or '2m'; an invalid value fails at startup naming the option, and 0 (the default) means no timeout.
The timeout applies to each attempt, not to the whole call. It covers the attempt’s interceptors and the reading of the body; for responseType: 'stream' and 'response' it stops when the headers arrive, so a long download is not cut off. When it elapses, the attempt fails with an HttpTimeoutError and is retried like any other transient failure.
To cancel a request pass an AbortSignal as signal. It is combined with the timeout; a cancelled request rejects with the signal’s reason, as fetch does, and is never retried, even while it waits between attempts. Because of retries a call can take up to attempts × timeout plus the waits, so to bound the whole call pass a deadline as the signal:
async findOneWithDeadline(isbn: string): Promise<BookInfo> {
const { data } = await this.catalog.get<BookInfo>('/books/:isbn', {
params: { isbn },
signal: AbortSignal.timeout(10_000),
});
return data;
}
The deadline rejects with a DOMException named TimeoutError, which toHttpException() maps to 504 Gateway Timeout, like an HttpTimeoutError. To combine it with another signal use AbortSignal.any([signal, AbortSignal.timeout(10_000)]). This is the same idea as the RxJS timeout() operator of an interceptor, but applied to the outgoing call instead of the incoming request.
Retries
Retries are on by default. Without any configuration a client makes up to 3 attempts for GET, HEAD, OPTIONS, PUT and DELETE, the methods HTTP defines as idempotent, after connection errors (refused or reset connections, including a reset while the body is read), per-attempt timeouts, and 408, 429, 500, 502, 503 and 504 responses.
Between attempts the client waits with exponential backoff and full jitter: a random delay of up to 200 ms after the first attempt, up to 400 ms after the second and so on, capped at 30 seconds. A Retry-After header (seconds or an HTTP date) replaces the backoff; when it asks for more than the cap, the client does not wait and returns or throws the 429 or 503 response at once. A request whose body is a stream is never retried, because the first attempt consumed it.
The retry option takes a number of attempts, false for a single attempt, or an object:
HttpClientModule.register({
name: 'catalog',
baseUrl: 'https://catalog.example.com',
retry: {
attempts: 4, // in total, including the first request
backoff: { delay: '500ms', factor: 2, maxDelay: '5s', jitter: 'full' },
retryIf: (error, attempt) => !(error instanceof HttpNetworkError),
methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],
statusCodes: [408, 429, 500, 502, 503, 504],
},
}),
Fields you omit keep their defaults. backoff can also be a function that receives the failed attempt’s number and error and returns a duration, and jitter is 'full', 'equal' (at least half the computed delay) or 'none'. retryIf is asked only about failures the client would retry anyway, so it can narrow the defaults but not widen them; use methods and statusCodes for that, noting that they replace the default lists. Settings are layered (forRoot(), then the client, then the request); each layer overrides only the fields it sets, and a later layer can turn retries back on after an earlier false.
POST and PATCH are not retried by default, because repeating them could, for example, charge a customer twice. When the upstream supports idempotency keys, send one and opt in for that request only. If the first POST receives a 503, the retry goes through, and both attempts carry the same header:
async createReview(review: CreateReviewDto): Promise<Review> {
const { data } = await this.catalog.post<Review>('/reviews', {
json: review,
headers: { 'idempotency-key': `review-${review.isbn}-${review.rating}` },
retry: { methods: ['POST'] },
});
return data;
}
Retries multiply along a chain of services: if A calls B, B calls C and every hop makes three attempts, one failing request to C becomes nine calls, arriving exactly when C is struggling. Retry at one layer only (usually the service that calls the external dependency) and set retry: false on clients that call your own services. Circuit breakers, bulkheads and idempotency middleware are covered in resilience, idempotency and locks.
Handling errors
A request rejects with one of four errors, all extending HttpClientError:
| Error | When | Fields |
|---|---|---|
|
The upstream answered with a non-2xx status. |
|
|
The |
|
|
The connection failed: DNS, a refused or reset connection, TLS, or a redirect refused by |
|
|
A successful response is not valid JSON although it was expected to be. |
|
A cancelled request rejects with the signal’s reason, errors thrown by interceptors propagate unchanged, and a request the client refuses to send (for example an invalid params value) rejects with a TypeError. To receive non-2xx responses as values, set throwOnHttpError: false on the client or the request and check ok or status yourself.
An HttpClientError that escapes a route handler becomes a 500. For your callers a failing upstream is usually a 502 Bad Gateway, and one that does not answer in time a 504 Gateway Timeout; toHttpException() maps the errors accordingly: HttpResponseError becomes 502 (or the upstream status and body when the status is listed in forward), HttpTimeoutError and the TimeoutError of an AbortSignal.timeout() deadline become 504, HttpNetworkError and HttpParseError become 502, and anything else, including an HttpException, is returned unchanged.
import { NotFoundException } from '@nestjs/common';
import { HttpResponseError, toHttpException } from '@nestjs/http-client';
async findOne(isbn: string): Promise<BookInfo> {
try {
const { data } = await this.catalog.get<BookInfo>('/books/:isbn', { params: { isbn } });
return data;
} catch (error) {
if (error instanceof HttpResponseError && error.status === 404) {
throw new NotFoundException(`Book ${isbn} not found`);
}
throw toHttpException(error);
}
}
Against the example upstream, GET /catalog/books/missing (upstream 404) and GET /catalog/books/broken (upstream 500 with a body describing its internals) answered:
{"message":"Book missing not found","error":"Not Found","statusCode":404}
{"message":"Bad Gateway","statusCode":502}
By default the upstream body is not passed on, since it may describe the upstream’s internals. To forward selected statuses with their body (for instance a 422 that tells the caller what to fix) pass toHttpException(error, { forward: [422] }), or forward: true for every status. The original error is kept as the exception’s cause.
Nest’s built-in exception filter does not log an HttpException, so a 502 or 504 would leave no trace; toHttpException() therefore logs every failure it turns into a 5xx with the HttpClient context, for example:
ERROR [HttpClient] GET http://127.0.0.1:49596/books/broken failed with 500 Internal Server Error; answering 502 Bad Gateway
ERROR [HttpClient] GET http://127.0.0.1:49596/books/slow timed out after 100ms; answering 504 Gateway Timeout
Set log: false when something else already logs upstream failures. Instead of mapping errors in every service you can let them leave the handler and map them in an exception filter, which keeps call sites free of try/catch:
import { ArgumentsHost, Catch } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { HttpClientError, toHttpException } from '@nestjs/http-client';
@Catch(HttpClientError)
export class HttpClientErrorFilter extends BaseExceptionFilter {
catch(error: HttpClientError, host: ArgumentsHost) {
super.catch(toHttpException(error), host);
}
}
Bound with @UseFilters(HttpClientErrorFilter) on a controller method, the filter turns a client timeout into a 504 response. An AbortSignal.timeout() deadline is a DOMException, not an HttpClientError, so add DOMException to @Catch() if you use deadlines. toHttpException() returns an HttpException; in a microservice or WebSocket handler convert to RpcException or WsException yourself.
Error messages and objects end up in logs, so the client keeps secrets out of them: a message never includes the response body, query values in the URL are masked (GET https://api.example.com/users?email=*), and the url, headers and body fields are non-enumerable, so loggers and JSON.stringify() leave them out.
Interceptors
An HTTP client interceptor wraps every request a client sends. It is a different concept from the Nest interceptors that wrap route handlers: it receives the outgoing request and a next function that sends it, and returns the web Response. The simplest form is a function (HttpClientInterceptorFn):
import type { HttpClientInterceptorFn } from '@nestjs/http-client';
export function apiKeyInterceptor(apiKey: string): HttpClientInterceptorFn {
return (request, next) => {
request.headers.set('x-api-key', apiKey);
return next(request);
};
}
The HttpRequest holds the method, the url (a URL), the headers (a Headers), the body, the signal, the attempt number and the request’s context; change it in place or pass a modified copy to next(). Interceptors see the response before the status check, so 4xx and 5xx responses reach them, and they must return what next() resolves to or a Response of their own. When an interceptor needs other providers, write a class that implements HttpClientInterceptor:
import { Injectable } from '@nestjs/common';
import type { HttpClientInterceptor, HttpHandler, HttpRequest } from '@nestjs/http-client';
import { CatalogTokenService } from './token.service.js';
@Injectable()
export class CatalogAuthInterceptor implements HttpClientInterceptor {
constructor(private readonly tokens: CatalogTokenService) {}
async intercept(request: HttpRequest, next: HttpHandler): Promise<Response> {
request.headers.set('authorization', `Bearer ${await this.tokens.getToken()}`);
return next(request);
}
}
Pass interceptors in the interceptors array of register() or forRoot(), or next to useFactory in the async variants, as CatalogModule above does. Nest resolves interceptor classes when the application initializes, so an interceptor may depend on a service that injects the same client. If the class is a provider anywhere in the application, that instance is used; otherwise Nest creates it inside the client’s own module. Interceptors run in the order listed, the first outermost, with the forRoot() ones before the client’s own, and they run once per attempt, so a retry gets a fresh token and request.attempt says which attempt it is. A failed attempt makes next() reject with an HttpNetworkError, an HttpTimeoutError or the abort reason, which makes an interceptor a good place to log upstream traffic:
import { Injectable, Logger } from '@nestjs/common';
import type { HttpClientInterceptor, HttpHandler, HttpRequest } from '@nestjs/http-client';
@Injectable()
export class HttpLoggingInterceptor implements HttpClientInterceptor {
private readonly logger = new Logger('HttpClient');
async intercept(request: HttpRequest, next: HttpHandler): Promise<Response> {
const target = `${request.method} ${request.url.origin}${request.url.pathname}`;
const start = performance.now();
try {
const response = await next(request);
const ms = Math.round(performance.now() - start);
this.logger.log(`${target} ${response.status} ${ms}ms (attempt ${request.attempt})`);
return response;
} catch (error) {
this.logger.warn(`${target} failed: ${(error as Error).name} (attempt ${request.attempt})`);
throw error;
}
}
}
The logger writes the path without the query string, which often carries personal data or keys. For tracing you do not need an interceptor: NestJS Observe records every outbound fetch call as a span and propagates the trace id to other Nest services (see health checks and observability).
Streaming responses
To pass a large response through without buffering it, set responseType: 'stream'. data is then a Node.js Readable, which a route handler can return inside a StreamableFile (see file upload and streaming):
// CatalogService
async cover(isbn: string): Promise<{ stream: Readable; type: string }> {
const { data, headers } = await this.catalog.get('/books/:isbn/cover', {
params: { isbn },
responseType: 'stream',
});
return { stream: data, type: headers.get('content-type') ?? 'image/png' };
}
// CatalogController
@Get('books/:isbn/cover')
async cover(@Param('isbn') isbn: string): Promise<StreamableFile> {
const { stream, type } = await this.catalog.cover(isbn);
return new StreamableFile(stream, { type });
}
Source: NestJS documentation: HTTP client, "Streaming responses" and File upload and streaming, "Streaming files".
The timeout stops once the headers arrive, while cancelling the signal still stops the download, and a non-2xx response is read and thrown as an HttpResponseError as usual. Do not pass the upstream’s content-length on as the length option of StreamableFile: fetch decompresses gzip and brotli bodies, so the stream can be longer than the header says. For the web ReadableStream use responseType: 'response' and read data.body.
Proxies, TLS and redirects
Node.js’s fetch runs on undici, whose default dispatcher keeps a pool of keep-alive connections, so the common case needs no configuration. To change the pool size, route through a proxy or use custom TLS settings (a private CA or client certificates), pass an undici dispatcher as the dispatcher option:
import { readFileSync } from 'node:fs';
import { Module } from '@nestjs/common';
import { HttpClientModule } from '@nestjs/http-client';
import { Agent, ProxyAgent } from 'undici';
@Module({
imports: [
HttpClientModule.register({
name: 'partner',
baseUrl: 'https://partner.example.com',
redirect: 'error',
dispatcher: new Agent({
connections: 20,
connect: {
ca: readFileSync('certs/partner-ca.pem'),
cert: readFileSync('certs/client.pem'),
key: readFileSync('certs/client-key.pem'),
},
}),
}),
],
})
export class PartnerModule {}
// Through a proxy instead: dispatcher: new ProxyAgent('http://proxy.internal:3128')
Node.js does not expose these classes from a built-in module, so install the undici package yourself (@nestjs/http-client does not depend on it) and use the major version your Node.js release bundles (node -p process.versions.undici). That advice is not cosmetic. For example, Node.js 24.16 bundles undici 7.25, and an Agent from a freshly installed undici 8 makes every request fail with an HttpNetworkError whose cause is InvalidArgumentError: invalid onRequestStart method; installing undici@7 fixes it. A Node.js http.Agent (what Axios accepts as httpsAgent) does not work with fetch and fails to compile. To route every client through a proxy from the HTTP_PROXY, HTTPS_PROXY and NO_PROXY environment variables instead, start Node.js with --use-env-proxy (or set NODE_USE_ENV_PROXY=1) on a release that supports it.
The redirect option controls how fetch handles 3xx responses: 'follow' (default), 'error' (fails with an HttpNetworkError) or 'manual' (returns them; combine it with throwOnHttpError: false to read the location header). When fetch follows a redirect to another origin it drops the authorization and cookie headers but not others, so a client that authenticates with a custom header such as x-api-key should use redirect: 'error' unless the API relies on redirects.
Security
The client guards against a few common mistakes:
-
A client with a
baseUrlonly sends requests to its origin. An absolute URL on the same origin (a pagination link) works; one on another host throws before anything is sent, because the client’s headers and interceptors carry that upstream’s credentials. Use a client withoutbaseUrlto call arbitrary hosts, and never forward user-supplied URLs to a client that carries credentials (a server-side request forgery risk). -
Path parameters are encoded. Put request values in
paramsandqueryinstead of interpolating them into the path; aparamsvalue such as..throws. -
Credentials in URLs are refused. A
baseUrlor request URL that embeds a user name and password throws; send credentials in theauthorizationheader, read from configuration. A header value with a line break throws without quoting the value. -
Errors are safe to log, as described above.
Testing
A stub fetch is the easiest way to test code that calls an external API. Pass it to HttpClientModule.forRoot() and every client uses it while keeping its base URL, headers, retry policy and interceptors. The stub only has to return a standard Response. The example below is a Vitest spec (it works the same with Jest):
import { ConfigModule } from '@nestjs/config';
import { HttpClientModule } from '@nestjs/http-client';
import { Test } from '@nestjs/testing';
import { vi } from 'vitest';
import { CatalogModule } from './catalog.module.js';
import { CatalogService } from './catalog.service.js';
describe('CatalogService', () => {
const fetch = vi.fn<typeof globalThis.fetch>();
let catalog: CatalogService;
beforeEach(async () => {
fetch.mockReset();
process.env.CATALOG_URL = 'https://catalog.example.test/v1';
process.env.CATALOG_TOKEN = 'test-token';
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
HttpClientModule.forRoot({ fetch }),
CatalogModule,
],
}).compile();
await moduleRef.init();
catalog = moduleRef.get(CatalogService);
});
it('lists the books of an author', async () => {
fetch.mockResolvedValueOnce(Response.json([{ isbn: '1', title: 'Dune', authors: [] }]));
await expect(catalog.findByAuthor('herbert')).resolves.toHaveLength(1);
const [url] = fetch.mock.calls[0];
expect(String(url)).toBe(
'https://catalog.example.test/v1/authors/herbert/books?sort=title&tag=fiction&tag=classic',
);
});
it('retries a 503', async () => {
fetch
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json({ isbn: '1', title: 'Dune', authors: [] }));
await expect(catalog.findOne('1')).resolves.toMatchObject({ title: 'Dune' });
expect(fetch).toHaveBeenCalledTimes(2);
});
});
Give every call its own Response, as mockResolvedValueOnce() does: a body can be read only once, so mockResolvedValue() would hand an already-read response to a retry. A client that sets its own fetch option keeps it, since a client’s settings win over the forRoot() defaults; a client without one calls globalThis.fetch on every request, so vi.stubGlobal('fetch', stub) works too. For an end-to-end test of an application that already calls forRoot(), override its options provider (this replaces every forRoot() option except interceptors passed directly to forRoot() or next to useFactory, which keep running). To keep HTTP out of a test entirely, replace the client itself:
import { HTTP_CLIENT_MODULE_OPTIONS, getHttpClientToken } from '@nestjs/http-client';
const withStubFetch = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(HTTP_CLIENT_MODULE_OPTIONS)
.useValue({ fetch })
.compile();
const withFakeClient = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(getHttpClientToken('catalog'))
.useValue({ get: vi.fn().mockResolvedValue({ data: { isbn: '5', title: 'F', authors: [] } }) })
.compile();
Source: NestJS documentation: HTTP client, "Testing". See testing for Test.createTestingModule, overrides and e2e tests in general. Outside Nest, for example in a script, create a client with new HttpClient(options).
Run end to end against a local http server, the real client behaves as described above: GET with 503 then 200 succeeds on the retry, a POST with an idempotency key is retried, and a 404, invalid JSON, a hung endpoint and a closed port yield HttpResponseError, HttpParseError, HttpTimeoutError and an HttpNetworkError with cause.code set to ECONNREFUSED respectively.
Working with RxJS
The client returns Promises, but a request can join an RxJS pipeline. defer() starts the request when the Observable is subscribed to, while from() would start it immediately:
import { defer, Observable } from 'rxjs';
import type { HttpClient } from '@nestjs/http-client';
export function booksOf(http: HttpClient, author: string): Observable<BookInfo[]> {
return defer(async () => (await http.get<BookInfo[]>('/authors/:author/books', { params: { author } })).data);
}
// Unsubscribing aborts the request, which matters with switchMap().
export function fromRequest<T>(send: (signal: AbortSignal) => Promise<T>): Observable<T> {
return new Observable<T>((subscriber) => {
const controller = new AbortController();
send(controller.signal).then(
(value) => {
subscriber.next(value);
subscriber.complete();
},
(error) => subscriber.error(error),
);
return () => controller.abort();
});
}
Neither defer() nor from() cancels the request on unsubscribe, which is why fromRequest() connects unsubscription to an AbortController. A switchMap() over fromRequest() aborts the in-flight request to a hung endpoint when a second value arrives (the server sees the connection close).
Migrating from @nestjs/axios
The Axios-based package lives on in @nestjs/axios (version 12 on 2026-10-02). It is not part of @nestjs/common any more: Nest 8.0.0 deprecated HttpModule and HttpService in @nestjs/common in favour of this package, and Nest 9.0.0 removed them from @nestjs/common, so a tutorial that imports them from @nestjs/common predates Nest 9. HttpService methods return Observables, which is why code on it needs firstValueFrom() or lastValueFrom(). The .toPromise() method that older tutorials call on those Observables is deprecated in RxJS 7 (and scheduled for removal in RxJS 8): use firstValueFrom() when you want the first emission (an HTTP call emits one) and lastValueFrom() for the last emission of a stream; both reject on an empty stream unless you pass defaultValue, whereas toPromise() resolved undefined.
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { firstValueFrom, lastValueFrom } from 'rxjs';
@Injectable()
export class LegacyCatalogService {
constructor(private readonly httpService: HttpService) {}
async findOne(isbn: string): Promise<BookInfo> {
const { data } = await firstValueFrom(this.httpService.get<BookInfo>(`/books/${isbn}`));
return data;
}
async createReview(review: CreateReviewDto): Promise<Review> {
const response = await lastValueFrom(this.httpService.post<Review>('/reviews', review));
return response.data;
}
}
Source: NestJS documentation: HTTP client, "Migrating from @nestjs/axios" and RxJS: toPromise deprecation.
Both packages can be installed side by side while you migrate. The same service on @nestjs/http-client drops the Observable layer:
import { Injectable } from '@nestjs/common';
import { HttpClient } from '@nestjs/http-client';
@Injectable()
export class MigratedCatalogService {
constructor(private readonly http: HttpClient) {}
async findOne(isbn: string): Promise<BookInfo> {
const { data } = await this.http.get<BookInfo>('/books/:isbn', { params: { isbn } });
return data;
}
async createReview(review: CreateReviewDto): Promise<Review> {
const { data } = await this.http.post<Review>('/reviews', { json: review });
return data;
}
}
Both services, wired through HttpModule.registerAsync() with baseURL and HttpClientModule.registerAsync() with baseUrl respectively, return identical results for the same upstream. The following table maps the features:
@nestjs/axios |
@nestjs/http-client |
|---|---|
|
|
|
|
|
|
|
A module that exports the providers, passed in |
One |
Named clients, injected with |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
No underlying instance. Use the client’s options, or pass a custom function as |
|
|
|
|
|
|
No retries, unless you add |
Retries on by default for idempotent methods; |
|
|
|
No equivalent. Count the bytes of a stream instead. |
Two differences deserve a test before you switch: retries are on by default, so an endpoint that was called once may now be called up to three times on a transient failure, and the timeout is per attempt, so end-to-end latency budgets need a deadline signal.
Pitfalls
-
Calling the pre-1.0 package as if it were stable. Pin the version, and keep the
@nestjs/axiospath available until the package reaches 1.0 if you cannot tolerate option changes. -
Retrying non-idempotent calls without an idempotency key. Only opt
POSTandPATCHin when the upstream deduplicates. -
Stacking retries across layers. Set
retry: falseon clients that call your own services. -
Trusting the type argument.
get<BookInfo>()is a cast; validate untrusted payloads. -
Mismatched
undicimajors when passing adispatcher(see above). -
Forgetting the per-attempt timeout. Use
AbortSignal.timeout()to bound the whole call. -
Sending credentials to user-supplied URLs. A client with a
baseUrlrefuses other origins; keep it that way. -
Mapping errors only in some call sites. Use
toHttpException()or oneHttpClientErrorFilterso upstream failures do not leak as500.
Express and Fastify
The client is independent of the HTTP platform: it uses the global fetch, so nothing changes between Express 5 and Fastify 5. Only the parts that return a response to your caller, such as StreamableFile, are handled by the platform adapter (see file upload and streaming).
Where the book is outdated
The reference book, published in 2025 (see the section bibliography), predates the 2026 documentation restructure. Tutorials of that generation reach external APIs with HttpModule and HttpService from @nestjs/axios, unwrap Observables with .toPromise() (deprecated) or firstValueFrom(), and configure retries and timeouts through Axios or an extra library. That still works, and the migration section above shows the equivalent code, but the current documentation teaches @nestjs/http-client. Importing HttpService from @nestjs/common has been deprecated since Nest 8 and no longer works since Nest 9, which removed it.
What changed recently
-
The HTTP client chapter was rewritten around the fetch-based
@nestjs/http-clientand now includes a migration path from@nestjs/axios. -
@nestjs/http-clientis pre-1.0 (0.0.1on 2026-10-02), like several other first-party packages introduced in the same period (authentication, authorization, i18n, mail, storage and webhooks). -
The "Community guidelines" section of the dynamic modules chapter now cites it as the example of a per-module
baseUrl, which is described in dynamic modules. -
@nestjs/axioscontinues to be published (version 12 for Nest 12) for existing code.