NestJS HTTP 客户端
多数应用都要通过 HTTP 调用其他服务。@nestjs/http-client 基于 Node.js 自带的 fetch API,补充了基础 URL、默认请求头、JSON 请求体、路径和查询参数、类型化响应、超时、重试、拦截器以及具名客户端。把它配置为 Nest 模块后,即可像其他 provider 一样注入使用。
该包除了 @nestjs/common 和 @nestjs/core 没有其他依赖。所有方法都返回 Promise,因此可以用 await 等待请求,并用 try/catch 处理失败。偏好 Observable 的读者可参见后文“与 RxJS 配合”。
> 提示:该包替代本章此前介绍的、基于 Axios 的 @nestjs/axios 中的 HttpModule。@nestjs/axios 仍然可用;迁移方法见文末。
安装
首先安装软件包:
$ npm i --save @nestjs/http-client
入门
在发起请求的模块中导入 HttpClientModule,使用 register() 注册客户端:
文件:cats.module
import { Module } from '@nestjs/common';
import { HttpClientModule } from '@nestjs/http-client';
import { CatsService } from './cats.service.js';
@Module({
imports: [
HttpClientModule.register({
baseUrl: 'https://api.example.com/v1',
timeout: '5s',
}),
],
providers: [CatsService],
})
export class CatsModule {}
然后注入 HttpClient:
文件:cats.service
import { Injectable } from '@nestjs/common';
import { HttpClient } from '@nestjs/http-client';
import type { Cat } from './interfaces/cat.interface.js';
@Injectable()
export class CatsService {
constructor(private readonly http: HttpClient) {}
async findAll(): Promise<Cat[]> {
const { data } = await this.http.get<Cat[]>('/cats');
return data;
}
}
请求发往 https://api.example.com/v1/cats。相对 URL 会追加到 baseUrl,两者之间恰好保留一个 /,因此 /v1 前缀不会丢失;这与 new URL('/cats', base) 不同,后者会丢弃该前缀。未配置 baseUrl 的客户端必须使用绝对 URL。
与其他 register() 方法一样,每次调用都会为导入它的模块创建客户端。如果希望在每个模块中都能注入,设置 isGlobal: true。
具名客户端
应用通常会调用多个上游服务,每个服务有自己的基础 URL、凭据和超时。为各客户端指定 name:
文件:repos.module
@Module({
imports: [
HttpClientModule.register({
name: 'github',
baseUrl: 'https://api.github.com',
headers: { accept: 'application/vnd.github+json' },
}),
],
providers: [ReposService],
})
export class ReposModule {}
使用 @InjectHttpClient() 装饰器注入:
文件:repos.service
@Injectable()
export class ReposService {
constructor(@InjectHttpClient('github') private readonly github: HttpClient) {}
}
不指定名称时注册的是默认客户端,通过 HttpClient 注入。getHttpClientToken(name) 返回客户端的注入令牌,适用于测试与自定义 provider。
应用级默认设置
所有客户端共享的设置,例如 user-agent 请求头、超时、重试策略或日志拦截器,放在 HttpClientModule.forRoot() 中。在根模块导入一次即可;它始终是全局的。
文件:app.module
@Module({
imports: [
HttpClientModule.forRoot({
headers: { 'user-agent': 'cats-service/1.0' },
timeout: '10s',
}),
CatsModule,
ReposModule,
],
})
export class AppModule {}
forRoot() 本身不注册客户端,也不接受 baseUrl。客户端自己的设置优先于全局默认值,但有三项特殊规则:请求头合并,客户端可用 null 删除默认头;重试配置按字段合并;forRoot() 拦截器先于客户端自己的拦截器执行。
异步配置
要根据其他 provider(例如配置章节中的 ConfigService)生成客户端选项,使用 registerAsync():
文件:repos.module
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HttpClientModule, type Duration } from '@nestjs/http-client';
import { ReposService } from './repos.service.js';
@Module({
imports: [
HttpClientModule.registerAsync({
name: 'github',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
baseUrl: config.getOrThrow<string>('GITHUB_API_URL'),
headers: { authorization: `Bearer ${config.getOrThrow<string>('GITHUB_TOKEN')}` },
timeout: config.get<Duration>('GITHUB_TIMEOUT', '5s'),
}),
}),
],
providers: [ReposService],
})
export class ReposModule {}
工厂可以是异步函数,返回类型为 HttpClientFactoryOptions,选项与 register() 基本相同,但不包括 name、isGlobal、imports 和拦截器类。Nest 必须在工厂运行前用这些信息构建模块,因此它们应与 useFactory 并列。若工厂返回这些选项,应用会在启动时失败。
也可以不用工厂函数,而传入实现 HttpClientOptionsFactory 的类。Nest 在客户端模块内实例化该类,并调用它的 createHttpClientOptions()。
文件:github-client.config
@Injectable()
export class GithubClientConfig implements HttpClientOptionsFactory {
constructor(private readonly config: ConfigService) {}
createHttpClientOptions(): HttpClientFactoryOptions {
return { baseUrl: this.config.getOrThrow<string>('GITHUB_API_URL') };
}
}
HttpClientModule.registerAsync({
name: 'github',
imports: [ConfigModule],
useClass: GithubClientConfig,
});
要复用已有 provider 而不是创建私有副本,使用 useExisting: GithubClientConfig,并在 imports 中导入其导出模块。forRootAsync() 的方式相同;它的 useClass 或 useExisting 类需实现 HttpClientModuleOptionsFactory,提供 createHttpClientModuleOptions()。
发起请求
客户端提供各 HTTP 方法:get()、post()、put()、patch()、delete()、head()、options()。它们接收 URL 和可选配置对象,返回解析为 HttpResponse 的 Promise。通用的 request() 通过 method 选项指定 HTTP 方法。
文件:repos.service
@Injectable()
export class ReposService {
constructor(@InjectHttpClient('github') private readonly github: HttpClient) {}
async findAll(org: string): Promise<Repo[]> {
const { data } = await this.github.get<Repo[]>('/orgs/:org/repos', {
params: { org },
query: { type: 'public', sort: 'updated', topic: ['nest', 'typescript'] },
});
return data;
}
async createIssue(owner: string, repo: string, issue: CreateIssueDto): Promise<Issue> {
const { data } = await this.github.post<Issue>('/repos/:owner/:repo/issues', {
params: { owner, repo },
json: issue,
});
return data;
}
}
这些选项与 Nest 控制器读取的请求信息相对应:
• params 对路径中的 :name 片段进行 URI 编码并填充。片段缺少值时会抛错,即使完全省略 params 也一样。没有对应片段的键,以及会把请求转向其他端点的值(空字符串、.、..)也会抛错。
• query 是对象或 URLSearchParams,追加到 URL 已有的查询参数后。数组会重复参数名,如 topic=nest&topic=typescript;Date 转为 ISO 字符串;null 和 undefined 被跳过。上面的第一个请求发往 https://api.github.com/orgs/nestjs/repos?type=public&sort=updated&topic=nest&topic=typescript。
• json 通过 JSON.stringify() 序列化;如果没有手动设置内容类型,还会设置 content-type: application/json。
• body 原样传给 fetch,可为字符串、Buffer、FormData、URLSearchParams、Blob、Web 或 Node.js 流,以及异步可迭代对象。json 与 body 只能选一个。带请求体的 GET 或 HEAD 会在发送前抛错。
• headers 合并到客户端请求头之上;值为 null 时删除该头。
• timeout、retry、signal、throwOnHttpError、redirect、dispatcher 覆盖本次请求的客户端设置,详见后文。
• context 是可由拦截器读取的自由格式对象。
Repo[] 等类型参数描述 data,但运行时不会检查它,因此应验证不受自己控制的 API 响应。responseType 决定如何读取响应体:
responseType |
data |
|---|---|
'auto'(默认) |
内容类型为 JSON(包括 +json)时解析 JSON,否则返回字符串。空响应体、204、205、304 响应,以及 HEAD 请求返回 undefined。 |
'json' |
不论内容类型,均解析 JSON。 |
'text' |
字符串。 |
'arrayBuffer' |
ArrayBuffer。 |
'stream' |
Node.js Readable,参见“流式响应”。 |
'response' |
未读取响应体的 Web Response。 |
data 的类型随 responseType 确定,例如 responseType: 'text' 返回 HttpResponse<string>。成功响应在预期 JSON 却无法解析时,以 HttpParseError 拒绝。响应对象结构如下:
interface HttpResponse<T> {
status: number;
statusText: string;
ok: boolean;
headers: Headers; // a web Headers object: headers.get('etag')
data: T;
url: string; // the final URL, after redirects
request: HttpRequest; // the request that was sent, after interceptors ran
}
超时与取消
除了 Node.js 底层 HTTP 客户端 undici 的5分钟限制,fetch 自身没有超时机制。为每个客户端设置 timeout,可以使用毫秒数,也可使用 '500ms'、'5s'、'2m' 等时长字符串。无效值会在启动时失败,错误中指出选项名。默认值 0 表示不超时。
超时针对每次尝试,而不是整个调用,涵盖该次尝试的拦截器执行与响应体读取。responseType 为 'stream' 或 'response' 时,收到响应头即停止计时,避免截断长时间下载。超时后产生 HttpTimeoutError,像其他瞬时故障一样触发重试。
取消请求时,将 AbortSignal 传给 signal,它与超时机制共同生效。请求取消后以该信号的 reason 拒绝,与 fetch 一致;取消永远不会触发重试,即使发生在两次尝试间的等待阶段。
由于重试,总耗时可能达到 attempts × timeout,再加尝试间的等待。要限制整个调用,可传入具有截止时间的信号:
const { data } = await this.github.get<Repo>('/repos/:owner/:repo', {
params: { owner, repo },
signal: AbortSignal.timeout(10_000),
});
截止时间到达时产生名为 TimeoutError 的 DOMException,toHttpException() 会像处理 HttpTimeoutError 一样,把它映射为 504 Gateway Timeout。要与其他信号组合,使用 AbortSignal.any([signal, AbortSignal.timeout(10_000)])。
重试
默认启用重试。无需额外配置,客户端会对 HTTP 定义为幂等的 GET、HEAD、OPTIONS、PUT、DELETE 最多尝试3次。以下情况会重试:
• 连接错误,例如连接被拒绝或重置,包括读取响应体时的重置。
• 单次尝试超时。
• 收到 408、429、500、502、503、504 响应。
尝试之间采用指数退避和全抖动:第一次失败后随机等待最多200毫秒,第二次最多400毫秒,依此递增,上限30秒。若有 Retry-After 响应头,其秒数或 HTTP 日期覆盖退避时间;若要求的等待超过上限,则立即返回或抛出 429、503,完全不等待。请求体为流时永不重试,因为第一次已消费该流。
retry 可为尝试次数、表示只尝试一次的 false,或配置对象:
HttpClientModule.register({
name: 'github',
baseUrl: 'https://api.github.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],
},
}),
省略的字段保留默认值。backoff 也可以是函数,接收失败的尝试序号和错误,返回时长。jitter 可为 'full'、'equal'(至少等待计算时长的一半)或 'none'。retryIf 只在客户端原本就会重试的故障上调用,因此只能缩小范围,不能扩大;扩大范围应使用 methods 和 statusCodes。
retryIf 收到该次尝试的 HttpResponseError(包括 body)、HttpTimeoutError 或 HttpNetworkError。
重试设置依次叠加:forRoot()、客户端、单次请求。每层只覆盖自己设置的字段;前一层设为 false 后,后一层仍可重新启用。methods 与 statusCodes 则替换默认列表,而非追加。
POST 和 PATCH 默认不重试,因为重复操作可能造成重复扣款。如果上游 API 支持幂等键,可发送幂等键,并为该请求启用重试:
const { data } = await this.payments.post<Charge>('/charges', {
json: charge,
headers: { 'idempotency-key': `charge-${order.id}` },
retry: { methods: ['POST'] },
});
同一操作的每次尝试使用同一个键,上游因此返回原结果,不再生成第二笔扣款。如果在客户端级别设置 methods: ['POST'],由于会替换默认列表,也会停止 GET 的重试。
> 警告:重试会沿服务调用链相乘。A 调用 B、B 调用 C,若每段都尝试3次,一次失败请求可能变为9次对 C 的调用,而此时 C 已经面临故障。通常只在直接调用外部依赖的那一层重试;调用自有服务的客户端应设为 retry: false。
错误处理
请求可能以下列错误拒绝,它们均继承 HttpClientError:
| 错误 | 发生条件 | 字段 |
|---|---|---|
HttpResponseError |
上游返回非2xx状态。 | status、statusText、headers、body(解析后的 JSON,否则为文本)、method、url |
HttpTimeoutError |
最后一次尝试超过 timeout。 |
timeoutMs、method、url |
HttpNetworkError |
DNS、连接拒绝或重置、TLS 故障,或 redirect: 'error' 拒绝重定向。 |
cause(如 cause.code 为 ECONNREFUSED)、method、url |
HttpParseError |
成功响应预期为 JSON,却不是有效 JSON。 | status、statusText、headers、body(原始文本)、method、url |
取消请求以信号的原因拒绝;拦截器抛出的错误原样传播。客户端拒绝发送的请求,例如无效 params,以 TypeError 拒绝。要把非2xx响应作为正常返回值接收,在客户端或请求中设置 throwOnHttpError: false,自行检查 ok 或 status。
若这些错误直接离开路由处理器,Nest 会返回 500 Internal Server Error。但对于 API 调用方,上游故障通常应对应 502 Bad Gateway,上游未及时回应应对应 504 Gateway Timeout。toHttpException() 提供以下映射:
| 错误 | 转换结果 |
|---|---|
HttpResponseError |
502 Bad Gateway;若状态包含在 forward 中,则转发上游状态和响应体。 |
HttpTimeoutError,或 AbortSignal.timeout() 的 TimeoutError |
504 Gateway Timeout |
HttpNetworkError、HttpParseError |
502 Bad Gateway |
其他错误,包括 HttpException |
原样返回,由 Nest 正常处理。 |
文件:repos.service
async findOne(owner: string, repo: string): Promise<Repo> {
try {
const { data } = await this.github.get<Repo>('/repos/:owner/:repo', {
params: { owner, repo },
});
return data;
} catch (error) {
if (error instanceof HttpResponseError && error.status === 404) {
throw new NotFoundException(`Repository ${owner}/${repo} not found`);
}
throw toHttpException(error);
}
}
默认不向调用方传递上游响应体,因为其中可能透露上游内部信息。要转发某些状态及其响应体,例如包含修正提示的 422,使用 toHttpException(error, options),将 forward 设为 [422];设为 true 则转发全部状态。原始错误保留为异常的 cause。
Nest 内置异常过滤器不记录 HttpException,因此 502、504 原本可能不留下日志。toHttpException() 会以 HttpClient 为上下文,记录所有被转换为5xx的故障:
ERROR [HttpClient] GET https://api.github.com/repos/nestjs/nest failed with 503 Service Unavailable; answering 502 Bad Gateway
如果自定义异常过滤器等其他机制已经记录上游故障,可以设置 log: false。
也可让错误原样离开处理器,在异常过滤器中集中映射,避免每个服务都写 try/catch,并让外层拦截器仍能看到原始错误。
文件:http-client-error.filter
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);
}
}
AbortSignal.timeout() 的截止时间异常不是 HttpClientError,使用此功能时应为 @Catch() 添加 DOMException。toHttpException() 返回 HttpException;微服务或 WebSocket 处理器需要自行转换为 RpcException 或 WsException。
> 提示:客户端避免把机密信息写入可能进入日志的错误消息与对象。消息不包含响应体,URL 查询值会掩码,例如 GET https://api.example.com/users?email=***。url、headers、body 是不可枚举字段,代码仍可读取,但日志器和 JSON.stringify() 会跳过。
拦截器
拦截器包裹客户端发出的每个请求,接收请求对象与负责发送请求的 next 函数,返回 Web Response。最简单的形式是函数:
文件:api-key.interceptor
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);
};
}
HttpRequest 包含 method、url(URL 实例)、headers(Headers 实例)、body、signal、尝试序号 attempt 和请求 context。可以直接修改对象,或把修改后的副本传给 next()。拦截器在状态检查之前看到响应,因此也能看到4xx、5xx。它必须返回 next() 的结果,或自己的 Response。
如果需要其他 provider,可编写实现 HttpClientInterceptor 的类:
文件:github-auth.interceptor
import { Injectable } from '@nestjs/common';
import type { HttpClientInterceptor, HttpHandler, HttpRequest } from '@nestjs/http-client';
import { GithubTokenService } from './github-token.service.js';
@Injectable()
export class GithubAuthInterceptor implements HttpClientInterceptor {
constructor(private readonly tokens: GithubTokenService) {}
async intercept(request: HttpRequest, next: HttpHandler): Promise<Response> {
request.headers.set('authorization', `Bearer ${await this.tokens.getToken()}`);
return next(request);
}
}
把拦截器放进 register() 或 forRoot() 的 interceptors 数组;异步版本则放在与 useFactory 并列的位置:
文件:repos.module
@Module({
imports: [
HttpClientModule.register({
name: 'github',
baseUrl: 'https://api.github.com',
interceptors: [GithubAuthInterceptor],
}),
],
providers: [GithubTokenService, GithubAuthInterceptor, ReposService],
})
export class ReposModule {}
Nest 在应用初始化时解析拦截器类,因此拦截器甚至可以依赖注入同一客户端的服务。若应用中已有该类的 provider,如上面的 GithubAuthInterceptor,则复用该实例;否则在客户端自身模块内创建实例,该模块可访问全局 provider 和 register() 的 imports 所导入的模块。
异步工厂也可在返回选项中提供拦截器,但只能是函数或实例,例如 apiKeyInterceptor(config.getOrThrow('API_KEY'))。
拦截器按列表顺序执行,第一个位于最外层;全局拦截器先于客户端拦截器。它们在每次尝试执行一次,所以重试可以获取新令牌;request.attempt 表示当前尝试序号。失败时,next() 以 HttpNetworkError、HttpTimeoutError 或取消原因拒绝,因此拦截器很适合记录上游请求日志。
文件:http-logging.interceptor
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;
}
}
}
在 HttpClientModule.forRoot() 中设置 interceptors: [HttpLoggingInterceptor] 后,它会记录所有客户端请求。日志只保留路径,不含往往携带个人信息或密钥的查询字符串。
> 提示:链路追踪无需拦截器。NestJS Observe 会把每个外发 fetch 调用记录为 span,并将 trace ID 传播到其他 Nest 服务。
流式响应
要转发大型响应而不把它全部缓冲到内存,设置 responseType: 'stream'。此时 data 是 Node.js Readable,可用 StreamableFile 从路由处理器返回。
文件:invoices.controller
@Get(':id/pdf')
async download(@Param('id') id: string): Promise<StreamableFile> {
const { data, headers } = await this.billing.get('/invoices/:id/pdf', {
params: { id },
responseType: 'stream',
});
return new StreamableFile(data, {
type: headers.get('content-type') ?? 'application/pdf',
});
}
收到响应头后超时计时停止,但取消 signal 仍可停止下载。非2xx响应照常读取,并抛出 HttpResponseError。不要把上游 content-length 直接用作 length:fetch 会解压 gzip、brotli,流的实际长度可能大于头部所示长度。若需要 Web ReadableStream,使用 responseType: 'response' 并读取 data.body。
代理、TLS 与重定向
Node.js 的 fetch 基于 undici,默认 dispatcher 维护 keep-alive 连接池,常见场景无需配置。要改变连接池大小、使用代理,或配置私有 CA、客户端证书等 TLS 选项,将 undici dispatcher 传给 dispatcher:
文件:partner.module
import { readFileSync } from 'node:fs';
import { Agent } from 'undici';
@Module({
imports: [
HttpClientModule.register({
name: 'partner',
baseUrl: 'https://partner.example.com',
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 {}
使用代理时传入 new ProxyAgent('http://proxy.internal:3128')。Node.js 没有通过内置模块导出这些类,因此需自行安装 undici,最好与当前 Node.js 内置的大版本一致;@nestjs/http-client 并不依赖它。Axios 的 httpsAgent 所接受的 Node.js Agent 不适用于 fetch,会导致编译失败。
如果要让所有客户端采用 HTTP_PROXY、HTTPS_PROXY、NO_PROXY 环境变量指定的代理,可在支持此功能的 Node.js 版本使用 --use-env-proxy,或设置 NODE_USE_ENV_PROXY=1。
redirect 控制3xx处理方式:默认 'follow' 跟随;'error' 以 HttpNetworkError 失败;'manual' 返回重定向响应。3xx不是成功状态,因此手动读取 location 时,还应设置 throwOnHttpError: false。跨源重定向时,fetch 会删除 authorization 和 cookie,但不会删除其他请求头。
如果使用 x-api-key 等自定义认证头,除非 API 依赖重定向,否则应使用 redirect: 'error'。
安全性
客户端针对一些常见错误提供保护:
• 设置 baseUrl 的客户端仅向同源地址发送请求。 同源绝对 URL(例如分页链接)可以使用,其他主机地址则在发送前抛错。客户端请求头与拦截器携带该上游的凭据,不能把它们发到用户输入或上游响应中的任意地址。调用任意主机时使用没有 baseUrl 的客户端。
• 路径参数会编码。 使用 params 与 query,不要用字符串插值拼接用户提供的路径。参数经过 URI 编码;.. 等路径参数会抛错,避免跳到其他端点。
• 拒绝 URL 中的凭据。 https://user:pass@example.com 形式的基础 URL 或请求 URL 会抛错,应通过 authorization 请求头发送凭据。包含换行的头部值也会抛错,但错误消息不复述其值。
• 错误可安全记录到日志,具体保护见“错误处理”。
测试
测试调用外部 API 的代码,最简单的方式是替换 fetch。把替身传给 HttpClientModule.forRoot(),所有客户端都会使用它,同时保留各自基础 URL、请求头、重试策略与拦截器。替身只需返回标准 Response。
文件:repos.service.spec
import { Test } from '@nestjs/testing';
import { HttpClientModule } from '@nestjs/http-client';
import { vi } from 'vitest';
import { ReposModule } from './repos.module.js';
import { ReposService } from './repos.service.js';
describe('ReposService', () => {
const fetch = vi.fn<typeof globalThis.fetch>();
let repos: ReposService;
beforeEach(async () => {
fetch.mockReset();
const moduleRef = await Test.createTestingModule({
imports: [HttpClientModule.forRoot({ fetch }), ReposModule],
}).compile();
await moduleRef.init();
repos = moduleRef.get(ReposService);
});
it('lists the public repositories of an organization', async () => {
fetch.mockResolvedValueOnce(Response.json([{ name: 'nest' }]));
await expect(repos.findAll('nestjs')).resolves.toEqual([{ name: 'nest' }]);
const [url] = fetch.mock.calls[0];
expect(String(url)).toBe(
'https://api.github.com/orgs/nestjs/repos?type=public&sort=updated&topic=nest&topic=typescript',
);
});
it('retries a 503', async () => {
fetch
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json([]));
await expect(repos.findAll('nestjs')).resolves.toEqual([]);
expect(fetch).toHaveBeenCalledTimes(2);
});
});
每次调用都应返回新的 Response,如上面的 mockResolvedValueOnce()。响应体只能读取一次,若使用 mockResolvedValue(),重试可能收到已读过的响应。客户端显式设置的 fetch 优先于全局默认值;没有自定义值时,每次请求读取 globalThis.fetch,因此 vi.stubGlobal('fetch', stub) 也有效。
对已经调用 forRoot() 的应用做端到端测试时,覆盖其选项。这样会替换所有 forRoot() 选项,但直接传给 forRoot()、或与 useFactory 并列传入的拦截器仍会执行。
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(HTTP_CLIENT_MODULE_OPTIONS)
.useValue({ fetch })
.compile();
若完全不希望测试涉及 HTTP,可通过 overrideProvider(getHttpClientToken('github')) 替换客户端。Nest 之外,例如脚本中,可用 new HttpClient(options) 创建客户端。
与 RxJS 配合
要使用 RxJS 运算符组合请求,把请求包裹为 Observable。defer() 等到订阅时才发起请求;from() 则立即开始。
import { defer } from 'rxjs';
const cats$ = defer(() => this.http.get<Cat[]>('/cats'));
二者都不会在取消订阅时自动取消请求。若需要这种行为,例如配合 switchMap(),把取消订阅连接到 AbortController:
import { Observable } from 'rxjs';
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();
});
}
const cats$ = fromRequest((signal) => this.http.get<Cat[]>('/cats', { signal }));
从 @nestjs/axios 迁移
@nestjs/axios 仍然可用,迁移期间两包可以并存。功能对应关系如下:
@nestjs/axios |
@nestjs/http-client |
|---|---|
HttpModule.register() 与 baseURL |
HttpClientModule.register() 与 baseUrl |
HttpModule.registerAsync() |
HttpClientModule.registerAsync(),还可接受 name |
HttpModuleOptionsFactory / createHttpOptions() |
HttpClientOptionsFactory / createHttpClientOptions() |
registerAsync() 中的 extraProviders |
通过 imports 导入导出这些 provider 的模块 |
每个导入模块一个 HttpService |
具名客户端,通过 @InjectHttpClient(name) 注入 |
HttpService 方法返回 Observable |
HttpClient 方法返回 Promise |
firstValueFrom(this.httpService.get(url)) |
await this.http.get(url) |
post(url, data) |
post(url, options),数据放在 json 或 body |
请求配置中的 params |
改为 query。数组重复参数名,而 Axios 使用 key[]=。新客户端中的 params 专指填充 :name 路径片段,不匹配的键会抛错。 |
response.headers['etag'] |
response.headers.get('etag') |
AxiosError 的 error.response?.status |
HttpResponseError 的 error.status 与 error.body |
error.code === 'ECONNABORTED' |
HttpTimeoutError |
validateStatus: () => true |
throwOnHttpError: false |
axiosRef(Axios 实例) |
无底层实例;使用客户端选项,或传入自定义 fetch 函数。 |
axiosRef.interceptors.request.use() 和 response.use() |
interceptors,一个函数同时处理请求与响应。 |
httpsAgent 或 proxy |
dispatcher,即 undici Agent 或 ProxyAgent。 |
maxRedirects: 0 |
redirect: 'manual' |
默认不重试,除非添加 axios-retry |
默认重试幂等方法;retry: false 恢复 Axios 的行为。 |
timeout 针对整个请求 |
timeout 针对每次尝试,也可使用 '5s' 等字符串。 |
onUploadProgress、onDownloadProgress |
无直接对应功能;可统计流的字节数。 |
来源:HTTP client,NestJS 官方文档;本次对照官方仓库的 fetch 版 @nestjs/http-client 章节。Copyright (c) 2017-present Kamil Myśliwiec。文档按 MIT License 授权。
MIT 许可声明
Copyright (c) 2017-present Kamil Myśliwiec <http://kamilmysliwiec.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.











暂无评论内容