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.

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:

cats.module.ts and cats.service.ts: the default client
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.

catalog.module.ts (excerpt) and catalog.service.ts (excerpt)
@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.

app.module.ts (the Bookshelf root module)
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).

catalog.module.ts
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().

catalog.config.ts
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.

catalog.service.ts
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:

  • params fills the :name segments of the path, URI-encoded. A segment without a value throws, even when params is 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 ..).

  • query is an object or a URLSearchParams, appended to any query already in the URL. Arrays repeat the key, Date values become ISO strings, and null and undefined values are skipped.

  • json is serialized with JSON.stringify() and sets content-type: application/json unless you set one. body is passed to fetch as is (string, Buffer, FormData, URLSearchParams, Blob, a stream or an async iterable). Pass one or the other, not both; a GET or HEAD with a body throws before anything is sent.

  • headers is merged over the client’s headers; a null value removes one.

  • timeout, retry, signal, throwOnHttpError, redirect and dispatcher override the client’s settings for one request.

  • context is 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

'auto' (default)

Parsed JSON for JSON content types (including +json), otherwise a string; undefined for an empty body, for 204, 205 and 304 responses and for HEAD requests.

'json'

Parsed JSON, whatever the content type.

'text'

A string.

'arrayBuffer'

An ArrayBuffer.

'stream'

A Node.js Readable (see Streaming responses).

'response'

The web Response, body unread.

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:

catalog.service.ts (excerpt)
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.

flowchart TD A[Attempt n starts] --> B[Interceptors run, then fetch] B --> C{Outcome} C -->|2xx| D[Resolve HttpResponse] C -->|timeout, connection error, 408/429/500/502/503/504| E{Method retryable, attempts left, retryIf agrees?} C -->|other non-2xx| F[Reject HttpResponseError] C -->|signal aborted| G[Reject with the signal reason, no retry] E -->|yes| H[Wait: backoff or Retry-After] --> A E -->|no| I[Reject with the last error]

The retry option takes a number of attempts, false for a single attempt, or an object:

Retry options on a client
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:

catalog.service.ts (excerpt)
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

HttpResponseError

The upstream answered with a non-2xx status.

status, statusText, headers, body (parsed JSON, otherwise text), method, url

HttpTimeoutError

The timeout elapsed on the last attempt.

timeoutMs, method, url

HttpNetworkError

The connection failed: DNS, a refused or reset connection, TLS, or a redirect refused by redirect: 'error'.

cause (for example cause.code set to ECONNREFUSED), method, url

HttpParseError

A successful response is not valid JSON although it was expected to be.

status, statusText, headers, body (raw text), method, url

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.

catalog.service.ts (excerpt)
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:

http-client-error.filter.ts
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):

api-key.interceptor.ts
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:

catalog-auth.interceptor.ts
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:

http-logging.interceptor.ts
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):

catalog.service.ts and catalog.controller.ts (excerpts)
// 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 });
}

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:

partner.module.ts
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 baseUrl only 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 without baseUrl to 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 params and query instead of interpolating them into the path; a params value such as .. throws.

  • Credentials in URLs are refused. A baseUrl or request URL that embeds a user name and password throws; send credentials in the authorization header, 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):

catalog.service.spec.ts
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:

Overriding the options or the client
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:

rx.ts
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.

legacy-axios.service.ts: code still on @nestjs/axios
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;
  }
}

Both packages can be installed side by side while you migrate. The same service on @nestjs/http-client drops the Observable layer:

migrated.service.ts
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

HttpModule.register() with baseURL

HttpClientModule.register() with baseUrl

HttpModule.registerAsync()

HttpClientModule.registerAsync(), which also takes a name

HttpModuleOptionsFactory with createHttpOptions()

HttpClientOptionsFactory with createHttpClientOptions()

extraProviders in registerAsync()

A module that exports the providers, passed in imports

One HttpService per importing module

Named clients, injected with @InjectHttpClient(name)

HttpService methods, which return Observables

HttpClient methods, which return Promises

firstValueFrom(this.httpService.get(url))

await this.http.get(url)

post(url, data)

post(url, options), with the data in json (or body)

params in the request config

query. Arrays repeat the key, where Axios writes key[]=. Here params fills :name path segments and throws for a key that matches none.

response.headers['etag']

response.headers.get('etag')

AxiosError with error.response?.status

HttpResponseError with error.status and error.body

error.code === 'ECONNABORTED'

HttpTimeoutError

validateStatus: () ⇒ true

throwOnHttpError: false

axiosRef (the Axios instance)

No underlying instance. Use the client’s options, or pass a custom function as fetch.

axiosRef.interceptors.request.use() and response.use()

interceptors, with one function for both the request and the response

httpsAgent or proxy

dispatcher, an undici Agent or ProxyAgent

maxRedirects: 0

redirect: 'manual'

No retries, unless you add axios-retry

Retries on by default for idempotent methods; retry: false restores the Axios behaviour.

timeout for the whole request

timeout per attempt, also as a string such as '5s'

onUploadProgress, onDownloadProgress

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/axios path available until the package reaches 1.0 if you cannot tolerate option changes.

  • Retrying non-idempotent calls without an idempotency key. Only opt POST and PATCH in when the upstream deduplicates.

  • Stacking retries across layers. Set retry: false on clients that call your own services.

  • Trusting the type argument. get<BookInfo>() is a cast; validate untrusted payloads.

  • Mismatched undici majors when passing a dispatcher (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 baseUrl refuses other origins; keep it that way.

  • Mapping errors only in some call sites. Use toHttpException() or one HttpClientErrorFilter so upstream failures do not leak as 500.

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-client and now includes a migration path from @nestjs/axios.

  • @nestjs/http-client is pre-1.0 (0.0.1 on 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/axios continues to be published (version 12 for Nest 12) for existing code.