Undici 现代 HTTP 客户端实战

完整讲解 Undici 现代 HTTP 客户端:fetch 与底层实现、连接池与 Agent 配置、pipelining 并发优化、流式响应与逐行解析、拦截器扩展、超时重试与指数退避,以及 SSRF 与 DNS 重绑定防护。

写 HTTP 调用时,axios 是多数人的默认选择;但 Node 18 之后,内置的 fetch(底层就是 Undici) 已经是更现代、更快、更省内存的答案。本文从 fetch 与底层实现讲起,深入连接池、流式响应、拦截器、超时重试,最后给出 SSRF 防护的实战注意。

1. 为什么选择 Undici

1.1 背景与现状

Undici 是 Node.js 官方维护的 HTTP/1.1 客户端,从 Node 18 起内置为全局 fetch 的实现。它比老的 http.request 与 axios 更快:

axios(http.request 封装)  → 每次请求可能新建连接
Undici                   → 连接池复用 + pipelining,性能显著更高

官方基准里 Undici 的吞吐通常比 axios 高 20% 到 60%,且内存占用更低,因为它不用维护插件链和适配层。

1.2 能力对比

维度Undiciaxiosnode-fetch
连接池Agent 内置需外部封装无
流式响应一等公民支持一般支持
拦截器Interceptor中间件无
中断AbortSignalAbortSignalAbortSignal
内置Node 18+ 自带依赖依赖

1.3 何时仍用 axios

已有生态依赖(拦截器、实例化)、团队习惯、mock 工具链成熟度。新项目或性能敏感场景,优先 Undici。

一句话:Undici = Node 内置 fetch 的底层引擎——连接池、流式、拦截器、中断全部原生,性能优于 axios,Node 18 起零依赖可用。


2. fetch 基础用法

2.1 最简单的 GET

const res = await fetch('https://api.example.com/users?page=1');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
console.log(users);

res.ok 在 200-299 时为 true,其余一律要显式抛错——fetch 对 4xx/5xx 不会自动 reject。

2.2 POST 与请求体

const res = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  },
  body: JSON.stringify({ name: 'plume', age: 18 }),
});
const created = await res.json();

2.3 错误类型区分

try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
  // 1. 网络错误:DNS 失败、连接拒绝 → 抛 TypeError
  // 2. 超时:AbortSignal.timeout 触发 → AbortError
  // 3. 业务错误:4xx/5xx → 不抛,靠 res.ok 判断
} catch (err) {
  if (err.name === 'AbortError') console.error('超时或已取消');
  else console.error('网络错误', err);
}

一句话:fetch 只对网络层错误和中断抛异常,4xx/5xx 必须自己判断 res.ok;超时用 AbortSignal.timeout 最省事。


3. 连接池与 Agent

3.1 复用全局 Agent

默认 fetch 使用全局 dispatcher,连接池自动复用 keep-alive 连接。频繁请求时不要每次新建 Agent,要显式传入共享实例:

import { Agent } from 'undici';

const agent = new Agent({
  connections: 100,           // 最大并发连接数
  pipelining: 1,              // 每连接管道请求数
  keepAliveTimeout: 30 * 1000, // keep-alive 空闲存活 30 秒
  keepAliveMaxTimeout: 60 * 1000,
});

// 把 agent 传给所有请求,实现连接复用
const res = await fetch(url, { dispatcher: agent });

3.2 按目标分流 Agent

不同上游配不同连接池,避免慢服务占满全局连接:

const agent = new Agent({ connections: 100 });

// 慢速上游单独一个池,互不影响
const slowAgent = new Agent({
  connections: 10,
  headersTimeout: 60 * 1000,  // 等响应头最长 60 秒
  bodyTimeout: 60 * 1000,     // 等响应体最长 60 秒
});

一句话:连接池 = Agent(fetch 用)+ Pool(低层 API),核心参数 connections 与 pipelining;多个不同特性的上游要拆成多个池隔离故障。


4. 请求性能优化

4.1 并发批量请求

串行等待是最常见的性能杀手。用 Promise.all 并发:

const ids = [1, 2, 3, 4, 5];
const results = await Promise.all(
  ids.map((id) => fetch(`https://api.example.com/users/${id}`).then((r) => r.json())),
);

4.2 限流并发思路

无脑并发会打爆上游或触发限流。手写一个简单限流器:

async function mapLimit(items, limit, fn) {
  const queue = [...items];
  const workers = Array.from({ length: limit }, async () => {
    while (queue.length) await fn(queue.shift());
  });
  await Promise.all(workers);
}

await mapLimit([1, 2, 3, 4, 5, 6, 7, 8], 3, async (id) => {
  await fetch(`https://api.example.com/users/${id}`);
});

4.3 压缩与缓存

请求时声明 Accept-Encoding: gzip, deflate 接受压缩;高频同 URL 响应再叠加本地缓存(见 nodejs-caching-strategies),减少不必要的网络往返。

一句话:性能三板斧 = 并发 Promise.all + 限流器防打爆上游 + keep-alive 连接复用;同一 URL 再叠加本地缓存。


5. 流式响应

5.1 大文件下载

直接 await res.text() 会把整个文件读进内存,大响应会爆内存。用流式写入磁盘:

import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

const res = await fetch('https://example.com/big-file.zip');
await pipeline(res.body, createWriteStream('/tmp/big-file.zip'));

5.2 逐行读取 SSE 场景

LLM 流式输出、日志流等场景要按行消费:

import { createInterface } from 'node:readline';

const res = await fetch('https://api.example.com/stream');
const lines = createInterface({ input: res.body });

for await (const line of lines) {
  if (line.startsWith('data:')) {
    const payload = line.slice(5).trim();
    if (payload === '[DONE]') break;
    console.log(JSON.parse(payload));
  }
}

5.3 半程中断释放

流式响应中途不再需要时,必须取消 body 释放连接:

const reader = (await fetch(url)).body.getReader();
await reader.read();
reader.cancel(); // 取消剩余读取,释放底层连接

一句话:流式 = res.body 接 pipeline 落盘 / 接 readline 逐行;半程不用必须 reader.cancel(),否则连接泄漏。


6. 拦截器与扩展

6.1 拦截器写法

Undici 拦截器是洋葱模型:请求经过每个拦截器,可加日志、鉴权、重试:

import { Agent, Interceptor } from 'undici';

const agent = new Agent();
agent.addInterceptor(new Interceptor(async (opts) => {
  opts.headers['User-Agent'] = 'plume-bot/1.0';   // 请求前注入
  const start = performance.now();
  const res = await opts.handler(opts);
  console.log(`[http] ${opts.method} ${opts.path} 耗时 ${Math.round(performance.now() - start)}ms`);
  return res;
}));

6.2 统一鉴权头

把 token 注入收敛到一处,业务代码不重复写:

const authInterceptor = new Interceptor(async (opts) => {
  opts.headers['Authorization'] = `Bearer ${getToken()}`;
  opts.headers['X-Request-Id'] = crypto.randomUUID();
  return opts.handler(opts);
});
agent.addInterceptor(authInterceptor);

6.3 装饰器函数方案

不想引入拦截器语法时,包一层函数同样能收敛公共逻辑:

async function apiFetch(path, opts = {}) {
  const res = await fetch(`https://api.example.com${path}`, {
    ...opts,
    headers: { 'Authorization': `Bearer ${getToken()}`, ...opts.headers },
    dispatcher: agent,
  });
  if (!res.ok) throw new ApiError(res.status, await res.text());
  return res.json();
}

一句话:拦截器适合统一注入与横切逻辑(鉴权、日志、追踪),装饰器函数适合小团队快速收敛;两者本质都是把重复代码抽到一处。


7. 超时重试与 SSRF 防护

7.1 三层超时

const agent = new Agent({ headersTimeout: 10 * 1000, bodyTimeout: 30 * 1000 });
const res = await fetch(url, {
  dispatcher: agent,
  signal: AbortSignal.timeout(15 * 1000), // 整请求总超时
});

7.2 指数退避重试

网络抖动是常态,安全重试要有 backoff + jitter:

function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

async function fetchWithRetry(url, { retries = 3, baseDelay = 200 } = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(10 * 1000) });
      if (res.ok) return res;
      if (res.status < 500 && res.status !== 429) throw new Error(`HTTP ${res.status}`);
    } catch (err) {
      if (attempt === retries) throw err;
      const delay = baseDelay * 2 ** attempt + Math.random() * 100; // 指数 + 抖动
      await sleep(delay);
    }
  }
}

7.3 SSRF 防护注意

接受用户传入 URL 的抓取服务必须防 SSRF:校验协议、解析后核对 IP,并防 DNS 重绑定:

import { lookup } from 'node:dns/promises';

async function assertSafeHost(rawUrl) {
  const url = new URL(rawUrl);
  if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('协议不允许');
  const { address } = await lookup(url.hostname);
  const ip = address.split('.').map(Number);
  if (ip[0] === 127 || ip[0] === 10 || ip[0] === 169) throw new Error('内网地址不允许');
}

一句话:可靠性 = headers/body/总时三层超时 + 指数退避重试;安全性 = 用户 URL 必须做协议与 IP 校验,防 SSRF 与 DNS 重绑定。


8. 踩坑清单

坑现象对策
4xx/5xx 不抛错拿到 500 却继续处理显式判断 res.ok
每次新建 Agent连接不复用,性能差全局共享一个 Agent
大响应直接 text()内存暴涨 OOM流式 pipeline 落盘
流式半程不取消连接泄漏reader.cancel()
串行发请求吞吐上不去Promise.all 并发
无脑并发打爆上游上游限流 429限流器 + 重试
重试无退避雪崩式重试指数退避 + 抖动
信任用户 URL被 SSRF 打内网协议/IP 校验
拦截器顺序错头没注入成功确认 addInterceptor 顺序

9. 总结

环节要点
选型Node 18+ 内置 Undici,性能优于 axios
基础fetch 不抛 4xx/5xx,必须判 res.ok
连接池Agent 全局复用,慢上游单独池
并发Promise.all + 限流器
流式res.body 接 pipeline / readline,半程 cancel
拦截器洋葱模型,统一鉴权日志
超时重试三层超时 + 指数退避抖动
SSRF协议校验 + IP 校验防内网探测

一句话记住:Undici 是 Node 官方的现代 HTTP 客户端——连接池复用、流式消费、拦截器扩展一应俱全,配合「三层超时 + 退避重试」的可靠性设计与「SSRF 校验」的安全底线,就能写出一套能上生产的内网与公网调用层。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js 优雅停机与健康检查实战
  2. Node.js 内存泄漏诊断实战
  3. BullMQ 后台任务队列实战