fzkit
v0.2.1
Published
Axios HTTP client factory with token injection, refresh, retry and in-flight dedupe
Maintainers
Readme
fzkit / HTTP Client
基于 axios 的 HTTP 客户端工厂。帮你统一处理:
- access token 注入
- 401 / 业务失效时的自动刷新
- 并发刷新合并与冷却
- 通用重试(网络错误 / 5xx)
- in-flight GET 请求合并
- 业务响应与错误钩子
安装
# npm
npm install fzkit axios
# yarn
yarn add fzkit axios
# pnpm
pnpm add fzkit axiosaxios 是 peer dependency,需要由业务项目自行安装(^1.7.0)。
快速开始
import axios from 'axios';
import { createHttpClient } from 'fzkit';
const http = createHttpClient({
axiosConfig: {
baseURL: '/api',
timeout: 15_000,
},
getAccessToken: () => localStorage.getItem('access_token'),
refreshAccessToken: async () => {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
throw new Error('refreshToken missing');
}
const { data } = await axios.post('/auth/refresh', { refreshToken });
if (!data?.accessToken) {
throw new Error('refresh response missing accessToken');
}
localStorage.setItem('access_token', data.accessToken);
if (data.refreshToken) {
localStorage.setItem('refresh_token', data.refreshToken);
}
return data.accessToken;
},
onAuthFailure: async () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// 跳转登录页
},
});
const profile = await http.get('/account/profile');
console.log(profile.data);返回值是标准 AxiosInstance,可继续使用 http.get/post/request 等 axios API。
设计原则
token 存储完全交给业务侧
库不内置 storage,只通过getAccessToken/refreshAccessToken读写。refreshAccessToken返回新 token /expiresAt时,业务必须写回getAccessToken使用的数据源。 配置对象里的显式undefined字段会被忽略,不会覆盖默认值。尽量保留 axios 语义
成功返回AxiosResponse;失败默认透传AxiosError。鉴权失败与瞬时失败分离
以下情况会走onAuthFailure:- 主会话路径且未启用刷新时,业务请求命中
unauthorizedStatusCode - 主会话路径 refresh 鉴权失败(
isRefreshFailure) refreshAccessToken返回空 token- 冷却期内拿不到可用 access token
以下情况不会走
onAuthFailure: - 请求级显式鉴权(
Authorization: ""或非空第三方 token) - 命中
skipRefreshUrls - 已用最新主会话 token 仍 401
- 网络错误 / 5xx
- 主会话路径且未启用刷新时,业务请求命中
Public API
import {
createHttpClient,
TokenRefreshManager,
type HttpClientOptions,
type AccessTokenResult,
type AccessTokenDetail,
type RetryPolicy,
type DedupePolicy,
type RequestDedupePolicy,
type BusinessResponseResult,
type ErrorContext,
type ErrorMessages,
} from 'fzkit/http-client';| 导出 | 说明 |
| --------------------------- | ------------------------------------------ |
| createHttpClient(options) | 创建带鉴权/刷新/重试/合并能力的 axios 实例 |
| TokenRefreshManager | 可选,多客户端共享同一套刷新与冷却状态 |
| 相关类型 | 配置与回调类型定义 |
配置项
最小可用
| 字段 | 必填 | 说明 |
| -------------------- | ---- | -------------------------------------------------------------- |
| axiosConfig | 是 | 透传给 axios.create |
| getAccessToken | 是 | 读取当前 access token |
| refreshAccessToken | 否 | 自定义刷新逻辑;不传则 401 直接走 onAuthFailure |
| onAuthFailure | 否 | 登录失效收尾(清 token、跳登录) |
| skipRefreshUrls | 否 | 会话无关路径:不 refresh、不 onAuthFailure(exact / prefix) |
Token 与 Headers
| 字段 | 默认 | 说明 |
| ----------------------- | ----------------- | -------------------------------------------------------------- |
| accessTokenHeaderName | "Authorization" | token 注入 / 显式鉴权判定使用的 header 名 |
| accessTokenPrefix | "Bearer" | token 前缀 |
| headersProvider | - | 每次请求动态注入 headers(可 async);不得提供鉴权 header |
| refreshBufferMs | 0 | 提前刷新窗口;getAccessToken 返回 AccessTokenDetail 时生效 |
说明:
getAccessToken返回空值 / 空字符串 /{ token: null|undefined }时,不注入鉴权 headergetAccessToken返回非 string 脏类型(number/boolean 等)时,安全归一为空 token,不抛 TypeErrorexpiresAt仅接受有效Date/string/ finitenumber;其余脏类型安全归一为null- 调用方显式传入
accessTokenHeaderName对应 header(默认Authorization,含空字符串)时,工厂不覆盖 - 请求级显式鉴权(空串匿名 / 非空第三方 token):
- 遇 401:不 refresh、不注入 store token、不
onAuthFailure - 即使
shouldRefreshByResponse返回 true:也不 refresh、不注入 store token
- 遇 401:不 refresh、不注入 store token、不
- 未写鉴权 header 的主会话路径:可注入 store token;启用 refresh 时 401 先 refresh,未启用则
onAuthFailure - 自定义
accessTokenHeaderName(如X-Access-Token)后,显式鉴权 / 注入 / provider 忽略都改看该字段(大小写不敏感) headersProvider不得提供accessTokenHeaderName(大小写不敏感);若返回同名 header 会被静默忽略- 鉴权 header 只来自:请求级 headers、
getAccessToken、refresh / store 直重试写回
const http = createHttpClient({
axiosConfig: { baseURL: '/api' },
getAccessToken: () => ({
token: localStorage.getItem('access_token') ?? '',
expiresAt: localStorage.getItem('expires_at'),
}),
refreshBufferMs: 60_000,
headersProvider: () => ({
'x-request-id': crypto.randomUUID(),
}),
});刷新控制
| 字段 | 默认 | 说明 |
| ------------------------- | ------------- | ------------------------------------------------------------------------------------- |
| refreshAccessToken | - | 刷新逻辑,返回类型与 getAccessToken 一致 |
| shouldRefreshByResponse | () => false | 业务响应触发刷新;仅主会话路径生效,显式鉴权 / skipRefreshUrls 会忽略 |
| refreshCooldownMs | 15000 | 刷新成功后的冷却期;与 refreshManager 互斥 |
| refreshManager | 内部实例 | 多客户端共享刷新状态;与 refreshCooldownMs 互斥,传入时必须同时传 refreshScopeKey |
| refreshScopeKey | - | 外部共享 manager 的鉴权域标识;同一 manager 的所有 client 必须严格相等 |
| unauthorizedStatusCode | 401 | 触发刷新流程的 HTTP 状态码 |
| refreshFailureCodes | [] | 仅用于 refresh 失败判定的业务 code |
| isRefreshFailure | 内置默认 | 判断 refresh 是否已到需要登出的程度 |
默认 isRefreshFailure:
- 非
AxiosError→ 不视为鉴权失败 - 无
response(网络错误)或status >= 500→ 不视为鉴权失败 status === unauthorizedStatusCode或命中refreshFailureCodes→ 鉴权失败refreshAccessToken返回空 token → 按鉴权失败处理(不进冷却、不重试原请求)
import { createHttpClient, TokenRefreshManager } from 'fzkit/http-client';
const sharedManager = new TokenRefreshManager(15_000);
const refreshScopeKey = Symbol('primary-auth');
const http1 = createHttpClient({
axiosConfig: { baseURL: '/api1' },
getAccessToken: () => tokenStore.accessToken,
refreshAccessToken: () => tokenStore.refresh(),
refreshManager: sharedManager,
refreshScopeKey,
onAuthFailure: () => tokenStore.logout(),
});
const http2 = createHttpClient({
axiosConfig: { baseURL: '/api2' },
getAccessToken: () => tokenStore.accessToken,
refreshAccessToken: () => tokenStore.refresh(),
refreshManager: sharedManager,
refreshScopeKey,
onAuthFailure: () => tokenStore.logout(),
});重试与请求合并
const http = createHttpClient({
axiosConfig: { baseURL: '/api' },
getAccessToken: async () => '',
retryPolicy: {
maxRetries: 2,
// shouldRetry / retryDelay 可自定义
},
dedupePolicy: {
enabled: true,
// generateKey 可自定义
},
});| 字段 | 默认 | 说明 |
| -------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------- |
| retryPolicy.maxRetries | 0 | 最大重试次数(不含首次;向下取整;硬上限 10) |
| retryPolicy.shouldRetry | 网络错误或 5xx(取消不重试) | 是否重试 |
| retryPolicy.retryDelay | 指数退避,上限 30s | 重试延迟;非有限/负数按 0ms;回调抛错停止重试并走 onError |
| dedupePolicy.enabled | false | 是否启用 in-flight GET 合并 |
| dedupePolicy.generateKey | method:baseURL:url:stableParams:headersProvider:authFingerprint | 合并 key |
补充:
- 鉴权失败(
unauthorizedStatusCode)优先于通用retryPolicy - dedupe 仅对 GET 生效
- 请求级可用
{ dedupePolicy: { enabled: false } }覆盖客户端级开关 - 请求级只能覆盖
enabled,不能改generateKey - dedupe 在 adapter 层生效,覆盖
http(url)/http.get/http.request等入口 - 内部 refresh / retry 在失败后 pending 已清理,可正常重放,不会自死锁
dedupe 语义边界
- 合并粒度:仅 in-flight;请求结束后相同 key 会重新发起
- 默认 key:
method + baseURL + url + stableParams + headersProvider 快照 + 鉴权指纹headersProvider快照:只纳入 provider 返回值(已剔除鉴权 header),不含调用方config.headers- 鉴权指纹:主会话 bare token / 显式 token /
__empty__(显式空)/__none__(无 Authorization) - 未配置
headersProvider时 provider 段为空 - 自定义
generateKey若不隔离身份,不同 token 的同 URL GET 可能被合并
- 取消:
- 每个消费者的
signal/cancelToken只取消自己 - 使用引用计数;全部消费者都离开后,才 abort 底层 shared 请求
- 已取消的请求不会创建 shared,避免孤儿请求
- 每个消费者的
- 超时:
- 每个消费者按自己的
timeout做本地超时 - shared 物理超时取当前参与者中的
max(timeout) - 任一参与者
timeout === 0(显式关闭)时,shared 不强制超时 - 客户端默认
timeout: 15_000,未单独配置时会按该值参与 max 计算
- 每个消费者按自己的
- 响应隔离:
- 成功响应深拷贝
data - 失败时
error.response.data也会深拷贝隔离 onBusinessResponse返回完整响应替换时,也会深拷贝data- 共享 refresh 失败时,每个 waiter 拿到独立错误实例
headers/status/request不保证引用共享(axios 管线可能重建);仅保证data隔离
- 成功响应深拷贝
- params 序列化:默认 key 稳定支持 plain object / array /
URLSearchParams/Date/Map/Set;循环引用会抛出可控错误 - 网络侧 config:底层请求以首个创建 shared 的 leader config 为准(除 cancel/timeout 外)
业务钩子
| 字段 | 说明 |
| -------------------- | ------------------------------------------------------------------------------ |
| onBusinessResponse | 成功响应拦截:void 继续,Error / throw 失败,完整 AxiosResponse 替换响应 |
| onError | 请求失败 / 刷新失败钩子,可替换错误;return Error 与 throw 都会进入该钩子 |
| errorMessages | 覆盖内部英文默认文案(refreshTokenExpired / loginExpired) |
const http = createHttpClient({
axiosConfig: { baseURL: '/api' },
getAccessToken: () => localStorage.getItem('access_token'),
onBusinessResponse: (response) => {
const data = response.data as { code?: number; message?: string };
if (data.code !== 0) {
return new Error(data.message ?? 'business error');
}
},
onError: (error, context) => {
console.error(`[${context.type}]`, error.message);
},
errorMessages: {
refreshTokenExpired: '登录已过期,请重新登录',
loginExpired: '登录已失效,请重新登录',
},
});默认请求流程
axios.create(axiosConfig)- 请求拦截:注入 token、合并
headersProvider - 成功响应:
- 主会话路径下
shouldRefreshByResponse判断是否刷新 - 显式鉴权 /
skipRefreshUrls即使返回 true 也不 refresh onBusinessResponse处理业务响应- 返回原始
AxiosResponse
- 主会话路径下
- 失败响应:
- 命中
unauthorizedStatusCode:- 显式鉴权(空 / 非空)或
skipRefreshUrls→ 只失败该请求,不 refresh、不onAuthFailure - 主会话 + 启用刷新:
- store token 已更新且与实际发出 token 不同 → 直接用最新 token 重试一次
- 否则 refresh 后重放一次
- 已用最新 token 仍 401 → 只失败该请求,不
onAuthFailure
- 主会话 + 未启用刷新 →
onAuthFailure
- 显式鉴权(空 / 非空)或
- 其他错误再走
retryPolicy
- 命中
- refresh 失败:
isRefreshFailure === true→onAuthFailure- 否则透传原始错误,并触发
onError({ type: "refresh" })
FAQ
1. 和直接用 axios 有什么区别?
createHttpClient 仍然返回 axios 实例,只是预装了鉴权、刷新、重试、合并等拦截逻辑。你继续用 get/post/request,不必换请求写法。
2. refresh 返回 400 会自动登出吗?
默认不会。只有这些情况会走 onAuthFailure:
- 主会话路径且未启用刷新(未传
refreshAccessToken)时,业务请求命中unauthorizedStatusCode - 主会话 refresh 返回
unauthorizedStatusCode(默认 401) - 或命中
refreshFailureCodes - 或
refreshAccessToken返回空 token - 或你自定义的
isRefreshFailure返回true - 或冷却期内拿不到可用 access token
以下不会登出:
- 请求级显式
Authorization(空串或非空) - 命中
skipRefreshUrls - 已启用刷新且业务请求在“最新 token”下仍 401
- 显式鉴权请求即使
shouldRefreshByResponse返回 true
如果你们后端用业务 code 表示 refresh token 失效,推荐:
refreshFailureCodes: [1001002],3. 为什么网络错误 / 5xx 不登出?
因为这通常不代表 refresh token 失效。默认策略是“瞬时失败可重试,鉴权失败才登出”。
4. skipRefreshUrls 怎么匹配?
只做 exact / prefix 路径匹配,不是子串 includes:
/auth匹配/auth、/auth/login- 不匹配
/user/auth-history、/authorization、/api/auth/login
语义:命中后 401 不 refresh、不 onAuthFailure;不禁止首次主会话 token 注入。
完全无鉴权请求请显式传 Authorization: ""。
5. 多个 http 客户端如何共享一次刷新?
创建共享的 TokenRefreshManager,通过 refreshManager 注入给多个 createHttpClient 实例。
共享 manager 表示同一鉴权域,不支持跨 tenant / 跨 token 源共享:
- 传入外部
refreshManager时必须同时传refreshScopeKey - 同一 manager 的所有 client 必须使用严格相等的
refreshScopeKey;缺失或不一致会在创建 client 时立即抛错 - 不同 tenant / token 源必须使用各自的
TokenRefreshManager - 一次合并 refresh 事务的鉴权失败只会触发一次
onAuthFailure - 使用发起该次 refresh 的 client 回调
- 所有共享 client 必须使用同一 token 数据源与等价 logout 行为
- join 他 client 的 in-flight refresh 后,waiter 通过自己的
getAccessToken()取最新 token(依赖 store 已由 leader 更新) - join 后 store token 未变化时直接失败,不会用旧 token 重试或执行另一个 client 的 refresh
TokenRefreshManager构造参数非法(NaN/Infinity/ 负数)会归一为默认15000refreshManager与refreshCooldownMs互斥;共享时冷却只能在new TokenRefreshManager(ms)配置- 某个服务在最新 token 下仍持续 401 时,只会失败该服务请求,不会拖垮其他服务,也不会无限 refresh
refreshAccessToken 回调内若再用 client 发请求:
- 仅保证同步启动的嵌套请求不会死锁(401 /
shouldRefreshByResponse短路) - 回调内先
await再发请求(同 client 或共享 manager 的另一 client)可能自等待;浏览器无法安全区分「嵌套再入」与「并发 waiter」 - 外部并发业务请求仍可 join 同一次 in-flight refresh
- 推荐:refresh 接口走
skipRefreshUrls或裸axios,不要在 refresh 回调里用同一套 client 打需鉴权接口
6. 请求级如何临时关闭 dedupe?
await http.get('/profile', {
dedupePolicy: { enabled: false },
});7. 不需要自动刷新怎么办?
不传 refreshAccessToken 即可。此时主会话请求遇到 unauthorizedStatusCode 会直接触发 onAuthFailure;显式鉴权 / skipRefreshUrls 仍不会登出。
8. 某些接口完全不想走鉴权/刷新?
可以继续用同一个 createHttpClient:
// 完全无凭证
await http.get('/public/config', {
headers: { Authorization: '' },
});
// 会话无关(可带主会话 token,但 401 不登出)
// 配置 skipRefreshUrls: ["/auth/login"]
await http.post('/auth/login', body);9. 请求级 dedupePolicy 的类型从哪来?
请求级 dedupePolicy 只存在于 createHttpClient 返回实例的请求配置类型
(FzkitAxiosRequestConfig,从 fzkit/http-client 导出)中:实例方法经类型交叉
提供该字段,不再通过 declare module "axios" 全局扩展,因此普通
axios.create() 实例的请求配置不接受它。该字段只对 createHttpClient
创建的实例有运行时效果。
SSE
createHttpClient() 返回的实例提供 http.sse()。它使用 Fetch 与 ReadableStream,不经过 Axios 的 adapter、interceptor、retry、dedupe 或响应转换链路。
最常用:会话感知的流式请求
const http = createHttpClient({
axiosConfig: { baseURL: 'https://api.example.com/v1' },
getAccessToken: () => store.token,
refreshAccessToken: () => refreshToken(), // 401 自动恢复后用新 token 重连
headersProvider: () => ({ 'x-device-id': deviceId }),
onAuthFailure: () => logout(),
onError: (error, { type }) => console.error(type, error), // 鉴权终态错误走客户端级 onError
});
const sub = http.sse('/chat/stream', {
method: 'POST',
json: { model, messages }, // 自动 JSON.stringify 并补 application/json
params: { conversationId }, // → /chat/stream?conversationId=…(axios 序列化语义)
lastEventId: savedCursor, // 断点续传:每次(重)连都带 Last-Event-ID
connectTimeoutMs: 10_000, // 建连超时 → 可恢复重连
idleTimeoutMs: 60_000, // open 后 60s 无字节 → 判定断线重连
maxRetries: 10, // 收到消息会归零重试计数
onOpen: (response) => {
console.log(response.status, sub.state); // sub.state === 'open'
},
onMessage({ event, data }) {
if (event === 'delta') render(data);
else if (event === 'done') sub.close();
},
onRetry({ attempt, delayMs, reason }) {
console.log(`${reason},${delayMs}ms 后第 ${attempt} 次重连`);
if (attempt > 5) sub.close(); // 在 onRetry 内调用 close() 可中止本次重连
},
onError(error) {
console.error(error.message, error.status, error.url); // SseError 带 url/status
},
onClose(reason) {
if (reason === 'error') showToast('流已断开');
},
});
sub.state; // 'connecting' | 'open' | 'retrying' | 'closed'(连接状态指示)
sub.lastEventId; // 最新确认游标,可持久化
sub.close(); // 手动关闭:onClose 收到 'manual',只触发一次- 支持
GET、POST、params(axios 序列化语义)和受控的 Fetch 字段;现代浏览器可直接使用,Node.js 18+ 内置 fetch 可直接使用,更早版本需注入兼容的fetch。 body走 fetch 原生语义(字符串 / Blob 等,不经过 axios 转换链);对象用json快捷选项自动序列化,显式body与content-type优先。- 订阅对象提供
state,可用于连接状态指示;在onRetry回调内调用close()可中止本次重连。 - 每次连接都会重新读取主会话 token 与
headersProvider;显式Authorization和skipRefreshUrls与 HTTP 请求一样隔离 refresh /onAuthFailure。 - 默认在网络异常、EOF、429、5xx 后指数退避重连(1s ~ 30s);
reconnect: false关闭传输重连,maxRetries限制重试次数,retryDelay可完全定制延迟(抛错视为终态失败)。 - 服务端
retry:提示约束 EOF 与空闲超时后的重连时机(缺失时退回指数退避);网络错误类重连不受提示抬升延迟。 connectTimeoutMs限制"发起请求到收到响应头"的时长,idleTimeoutMs限制 open 后无字节到达的时长;两者超时都按可恢复错误触发重连。- POST 自动重连只支持可重放 body;
ReadableStreambody 必须设置reconnect: false。 onMessage接收所有事件;未声明event:时事件名为message,data 保留原始字符串。默认异步处理器不保证按序完成;sequentialMessages: true可串行处理并暂停读取形成背压。onOpen/onMessage处理器出错(同步 throw 或异步 rejection)会通过onError上报;onOpen失败会终态关闭订阅,onMessage失败不会中断流。onRetry/onError/onClose为通知型回调,内部异常被忽略。- 鉴权终态错误(401 无法恢复)与 HTTP 请求一致走客户端级
onError(带type上下文),订阅级onError不会重复收到;订阅关闭仍会触发onClose('error')。 SseError携带status与最终请求url,便于多订阅场景排障。
断点续传
const subscription = http.sse('/events', {
lastEventId: savedCursor, // 从持久化游标恢复
onMessage(event) {
if (event.id) saveCursor(event.id); // 或直接读 subscription.lastEventId
console.log(event.event, event.data);
},
});
subscription.lastEventId; // 当前已确认的事件 id(服务端 id 或播种值)每次(重)连都会带上 Last-Event-ID,服务端按游标续传。
AI 对话流(OpenAI 兼容)
http.sse 天然匹配 OpenAI Chat Completions 的流式形态(POST + text/event-stream + 逐条 data: <JSON>):
const openai = createHttpClient({
axiosConfig: { baseURL: 'https://api.openai.com/v1' },
getAccessToken: () => apiKey, // 自动注入 Authorization: Bearer <apiKey>
});
function streamChat(messages: Array<{ role: string; content: string }>): Promise<string> {
return new Promise((resolve, reject) => {
let full = '';
const sub = openai.sse('/chat/completions', {
method: 'POST',
json: {
model: 'gpt-4o',
messages,
stream: true,
stream_options: { include_usage: true },
},
// LLM 流重放成本高(重复计费 + 重复输出):关闭自动重连,由调用方决定何时重试
reconnect: false,
idleTimeoutMs: 60_000,
onOpen(response) {
console.log(response.headers.get('x-request-id')); // 排障用
},
onMessage({ data }) {
if (data === '[DONE]') return sub.close(); // 流结束哨兵
const chunk = JSON.parse(data); // data 是原始 JSON 字符串
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) full += delta;
if (chunk.choices?.[0]?.finish_reason === 'stop') sub.close();
// include_usage 时结尾还有一条 choices 为空的 usage chunk,可选读 chunk.usage
},
onError(error) {
reject(error);
},
onClose(reason) {
resolve(full); // 可结合 reason 判断流是否完整结束(manual/stop 为正常)
},
});
});
}- 静态 key 用
getAccessToken注入即可,无需手写 header;自建网关的短期 token 可配refreshAccessToken,401 自动恢复后重连。 - 同形态的 OpenAI 兼容服务(DeepSeek / Ollama / LM Studio 等)复用同一段代码,换
baseURL与model即可。 data保持原始字符串、JSON.parse由调用方完成:不同服务的 chunk 形状不同,自动解析容易误导;需要时自己包 3 行即可。
独立使用(零 axios 依赖)
不需要 token 注入 / refresh / dedupe 时可直接用 fzkit/sse:
import { createSseClient } from 'fzkit/sse';
const sse = createSseClient({ fetch }); // Node.js 需注入 fetch
const subscription = sse('/events', { onMessage(event) {} });prepareHeaders / createAuthSession 依赖可供自行装配鉴权恢复;resolveUrl 依赖可注入自定义 URL 解析(含 params 处理)。独立路径下 params 不会被自动序列化。
测试
yarn workspace fzkit test测试分层与覆盖说明见 tests/README.md。
License
MIT
