Spree SDK 的 HTTP 错误恢复机制:空与非 JSON 错误体的状态码保持,以及 401 会话恢复闭环
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
本篇基于 changeset steady-http-error-recovery.md 展开,讲解 Spree 三个 TypeScript SDK 包(@spree/sdk、@spree/admin-sdk、@spree/seller-sdk)中一个关键健壮性修复:当服务端(或网关、代理)返回空错误体、非 JSON 文本或结构不合法的错误响应时,SDK 如何把真实的 HTTP 状态码原样保留在抛出的SpreeError中,而不再把这类响应误判为"网络故障"去盲目重试;并进一步打通 admin / seller 面板在收到 401 后刷新令牌、重放原请求的会话恢复链路。读完本文,你能理解 packages/sdk-core/src/request.ts 中错误解析与重试边界的完整实现,以及 packages/dashboard-core/src/providers/auth-provider.tsx 中 401 恢复回调的接线方式。
1. 这个 changeset 到底改了什么
.changeset/steady-http-error-recovery.md 是一份 patch 级别的 changeset,同时作用于三个包:
"@spree/sdk": patch "@spree/admin-sdk": patch "@spree/seller-sdk": patch正文只有两句话,却定义了两类明确的验收标准:
- 保留 HTTP 错误状态码:当服务器返回空错误体、非 JSON 错误体、或结构不符合约定的"畸形"(malformed)错误体时,SDK 抛出的错误必须仍然携带真实的
status(400/401/403/404/422/503……),而不是退化成某种无状态信息; - 不再把这类响应当作网络失败:它们不应触发"网络错误重试"分支,并且 admin 与 seller 客户端的会话恢复逻辑(
onUnauthorized)必须能够正常接手 401 响应。
问题背景可以这样理解:真实部署中,到达 SDK 的响应体未必来自 Spree 后端本身。负载均衡器、反向代理或 CDN 在故障时会直接吐出 HTML 错误页(<html>502 Bad Gateway</html>)、一段纯文本,甚至返回一个空 body 的 401。修复前,response.json()在这类 body 上解析失败而抛出异常,该异常会落进 SDK 的catch分支,被当作 fetch 网络错误处理——于是出现两个错误行为:一是 401/403/404 这类"服务器明确拒绝"的响应被当作网络抖动反复重试,放大无效流量;二是抛出的错误不是携带status的SpreeError,admin/seller 客户端的 401 恢复钩子因判断error instanceof SpreeError && error.status === 401失败而永远不触发,用户卡在过期令牌上而不是被自动刷新。
2. 错误解析:先按契约解析,失败则合成http_error
核心实现在 createRequestFn 的非 2xx 分支(request.ts#L214-L248):
if (!response.ok) { const isLastAttempt = attempt >= maxAttempts - 1 if ( !isLastAttempt && config.retryConfig && shouldRetryOnStatus(method, response.status, config.retryConfig, hasIdempotencyKey) ) { const retryAfter = response.headers.get('Retry-After') const delay = retryAfter ? Math.min(parseInt(retryAfter, 10) * 1000, config.retryConfig.maxDelay) : calculateDelay(attempt, config.retryConfig) await sleep(delay) continue } const errorBody: ErrorResponse | null = await response.json().catch(() => null) if ( typeof errorBody?.error?.code === 'string' && typeof errorBody.error.message === 'string' ) { throw new SpreeError(errorBody, response.status) } // Proxy error pages and empty bodies must not turn HTTP failures into network retries. throw new SpreeError( { error: { code: 'http_error', message: `Request failed with status ${response.status}`, }, }, response.status, ) }这里有几个值得注意的设计点:
重试发生在解析错误体之前。只要还有重试配额且状态码命中重试策略,就continue进入下一次尝试;只有"最后一次尝试仍然失败"时才去解析 body。这意味着无论 body 长什么样,重试预算的消耗都是确定的,不会出现"边解析边重试"的不确定行为。
response.json()被显式容错。await response.json().catch(() => null)把"body 不是合法 JSON"收敛为一个null结果,而不是让它向外抛出。随后用两次typeof严格校验:必须同时满足error.code是字符串、error.message是字符串,才认为是 Spree 的标准错误契约(ErrorResponse),构造携带真实code/message/details的SpreeError;否则走合成路径——
合成错误的契约是:
name: 'SpreeError',code: 'http_error';status为响应真实的 HTTP 状态码;message固定为Request failed with status <status>;details为undefined(解析不出的 body 没有可透传的字段级错误)。
源码里那行注释点明了本次修复的意图:"Proxy error pages and empty bodies must not turn HTTP failures into network retries."—— 正因为非 2xx 路径永远以"带 status 的 SpreeError"收尾,它才不会被外层catch分支(见下节)误伤。
SpreeError的完整定义在 request.ts#L40-L52:
export class SpreeError extends Error { public readonly code: string // 'http_error' 或服务端业务码 public readonly status: number // HTTP 状态码 public readonly details?: Record<string, string[] | ValidationErrorDetail[]> ... }调用方因此可以统一用err instanceof SpreeError+err.status做分支,而不必再去区分"这是 HTTP 层失败还是网络层失败"。
哪些 body 会落入http_error合成路径
packages/sdk/tests/retry.test.ts 中的参数化用例(retry.test.ts#L244-L268)给出了权威的"畸形 body 清单",全部断言:抛出的错误匹配{ name: 'SpreeError', code: 'http_error', status: <status>, message: 'Request failed with status <status>', details: undefined },且fetch 只被调用 1 次(即未重试):
| 状态码 | 响应 body | 说明 |
|---|---|---|
| 400 | <html>Bad request</html> | 代理/网关的 HTML 错误页 |
| 401 | '' | 空 body(认证代理常见) |
| 403 | Access denied | 纯文本 |
| 404 | { | 截断的 JSON |
| 422 | null | JSON 但不是对象 |
| 422 | {} | 缺少error字段 |
| 422 | {"error":null} | error为 null |
| 422 | {"error":"Invalid address"} | error是字符串而非对象 |
| 422 | {"error":{"code":422,"message":"Invalid address"}} | code是数字而非字符串 |
| 422 | {"error":{"code":"invalid","message":[]}} | message是数组而非字符串 |
这张表同时回答了"什么叫 malformed":契约校验是逐字段类型的,code必须是string(数字 422 不合格)、message必须是string(数组不合格)。任何一层不满足,都降级为http_error,但状态码不丢。
另外两个用例进一步锁死了行为边界(retry.test.ts#L270-L296):
does not retry a rejected cart write as a network failure:对carts.create()(POST)返回 403 纯文本Forbidden,断言只调用 1 次 fetch——写请求被拒绝时,重试既不安全(可能已产生副作用)也无意义;respects the configured status retry policy for non-JSON errors:当用户把retryOnStatus收紧为[429]时,返回非 JSON 的 503 也不再重试——重试与否只取决于状态码策略,与 body 形态无关。
3. "保留状态码"为什么关键:与网络失败分支的隔离
createRequestFn的重试循环外层包了一个catch(request.ts#L265-L283),专门处理 fetch 本身抛出的异常(连接重置、DNS 失败等真正的网络层故障):
} catch (error) { if (error instanceof SpreeError) { throw error // HTTP 层失败直接向上抛,不做网络重试 } const isLastAttempt = attempt >= maxAttempts - 1 if ( !isLastAttempt && config.retryConfig && shouldRetryOnNetworkError(method, config.retryConfig, hasIdempotencyKey) ) { const delay = calculateDelay(attempt, config.retryConfig) await sleep(delay) continue } throw error }第一行if (error instanceof SpreeError) throw error就是本次修复的"分水岭":非 2xx 响应现在一律以SpreeError抛出,在这里被立即放行;只有真正的网络异常(非SpreeError)才进入网络重试判断,而网络重试本身也有安全约束(shouldRetryOnNetworkError):仅GET/HEAD,或请求携带幂等键时才允许重试,避免对不安全的写操作盲目重发。
修复前的故障模式正好落在这个缝隙里:response.json()抛出的SyntaxError既不是SpreeError,又发生在"已有响应"的前提下,却被catch当成网络错误——401 空 body 会被反复重试(浪费配额且掩盖问题),而 401 恢复钩子因拿不到status === 401的SpreeError而无法启动。修复后,"有响应"与"无响应"两类失败被干净地分离:前者永远产出带状态码的SpreeError,后者才适用网络重试。
4. 重试策略的默认值与配置项
错误恢复建立在既有重试机制之上,参数定义与默认值分别在 RetryConfig 和 resolveRetryConfig:
| 配置项 | 默认值 | 含义 |
|---|---|---|
maxRetries | 2 | 最多重试次数(加上首次共 3 次尝试) |
retryOnStatus | [429, 500, 502, 503, 504] | 触发状态码重试的列表,可自定义 |
baseDelay | 300ms | 指数退避基数 |
maxDelay | 10000ms | 单次退避上限,也用于钳制Retry-After |
retryOnNetworkError | true | 是否对网络层异常重试 |
三个补充规则:
- 退避公式(calculateDelay):
baseDelay * 2^attempt + random * baseDelay,再加随机抖动避免重试风暴,结果不超过maxDelay; Retry-After优先:429 等响应若带Retry-After: <秒>头,延迟取min(秒 * 1000, maxDelay)(request.ts#L222-L226),retry.test.ts#L341-L362 实测Retry-After: 1时总耗时 ≥ 900ms;- 写请求的幂等保障:启用重试时,非 GET/HEAD 请求会自动附带
Idempotency-Key头,值形如spree-sdk-retry-${randomUUID}(request.ts#L192-L200);调用方显式传入的idempotencyKey优先于自动生成值。没有幂等键的写请求只在 429 时重试(shouldRetryOnStatus),而 5xx 一律不重试——这是"稳"的另一层含义。传retry: false则完全关闭重试,也不发送幂等键,此时 502 空 body 同样以携带status: 502的SpreeError一次抛出(retry.test.ts#L312-L321)。
5. 会话恢复:admin 与 seller 客户端的 401 闭环
changeset 后半句"allow admin and seller session recovery to handle unauthorized responses"落在两个客户端工厂函数里。
createAdminClient(packages/admin-sdk/src/client.ts)的核心是包在基础请求函数外的try/catch:
try { return await makeRequest() } catch (error) { // On 401, try the unauthorized handler (token refresh) and retry once if ( error instanceof SpreeError && error.status === 401 && unauthorizedHandler && !path.includes('/auth/') // Don't retry auth endpoints ) { const shouldRetry = await unauthorizedHandler() if (shouldRetry) { return makeRequest() } } throw error }seller 侧(createSellerClient,packages/seller-sdk/src/client.ts)是逐行同构的实现,仅基础路径(/api/v3/seller)与租户头(X-Spree-Seller-Id)不同。两者的恢复契约一致,由Client接口的 JSDoc 写明:
Register a callback that fires on 401 responses. Return
trueto retry the original request (after refreshing the token viasetToken). Returnfalseto let the error propagate.
几个细节决定了这个闭环在边界情况下也是稳的:
!path.includes('/auth/')保护:登录/刷新等认证端点自身返回 401 时不再回调,避免"刷新失败 → 再调刷新 → 再失败"的递归;- 恰好重试一次:
makeRequest()在回调返回true后被重新调用一次;若第二次仍是 401,异常直接上抛,不再循环; - 前提条件:
error instanceof SpreeError && error.status === 401。这正是第 2 节修复的价值所在——认证代理返回"空 body 的 401"时,修复前这里拿到的是 JSON 解析异常而非SpreeError,恢复钩子形同虚设;修复后空 body 401 以code: 'http_error', status: 401的SpreeError到达此处,钩子正常触发; - 凭据模式:两个客户端的
credentials默认'include',保证/auth/refresh所需的刷新令牌 cookie 随请求发出(admin client.ts#L18-L23)。
Dashboard 侧的接线
packages/dashboard-core/src/providers/auth-provider.tsx 演示了onUnauthorized的真实宿主实现(auth-provider.tsx#L208-L215):
getApiClient().onUnauthorized(async () => { const success = await refreshAccessToken() if (success) scheduleRefresh() return success })其中refreshAccessToken用一个refreshPromiseRef把所有并发调用收敛到同一个auth.refresh()请求上(auth-provider.tsx#L141-L148),注释写明目的:"Serialize concurrent refresh calls so StrictMode/HMR/401-retry don't double-rotate."—— 页面同时发出多个请求、多个请求同时 401 时,令牌只会被轮换一次,其余调用方等待同一个 Promise。刷新成功后通过applySession调用client.setToken(accessToken)更新客户端令牌并重置定时刷新(JWT 默认 5 分钟 TTL,提前约 30 秒刷新,auth-provider.tsx#L50-L51);刷新失败则clearSession()清空本地会话并返回false,401 错误随之上抛给业务层。
6. 调用方实践与验证方式
对使用这三个 SDK 的应用,修复后推荐的错误处理形态是:
import { SpreeError } from '@spree/sdk' // admin-sdk / seller-sdk 同样基于它 try { const products = await client.products.list() } catch (err) { if (err instanceof SpreeError) { if (err.status === 404 && err.code === 'http_error') { // 状态码可信,但 body 不是 Spree 标准错误(可能是网关 404 页) } else if (err.status >= 400 && err.status < 500) { // 客户端错误:展示 err.message / err.details,不要重试 } // err.code === 'http_error' 时 err.details 为 undefined } else { // 真正的网络层异常(连接失败等) } }需要记住的边界:
http_error只可能出现在非 2xx 且 body 不合契约的响应上;成功路径(200/201/202/204)不受影响,204 与无 JSON 的 202 返回undefined;- 状态码重试默认只对
[429, 500, 502, 503, 504]生效,401/403/404/422 均不重试,所以"保留状态码"同时意味着"这类错误快速失败",调用方应依据status做产品级处理(跳转登录、提示权限等); - 自定义
retryOnStatus后,非 JSON 错误同样遵守新策略(见第 2 节 503 用例),行为可预期。
在仓库中验证这些行为的入口是 packages/sdk/tests/retry.test.ts 的Retry logic套件——重点看HTTP errors without a Spree error body分组,它覆盖了上表全部 10 种畸形 body、写请求不重试、自定义retryOnStatus、重试耗尽后保留最终状态码(503 连续 3 次尝试后仍以status: 503抛出,retry.test.ts#L298-L310)以及retry: false下的状态码保持。admin/seller 侧 401 恢复的对应测试位于 packages/admin-sdk/tests/auth.test.ts 与 packages/seller-sdk/tests/auth.test.ts。
7. 小结
这个 patch 级别的改动量很小,但划定了一条清晰的错误语义边界:
- 响应存在 → 状态码必达:任何非 2xx 响应,无论 body 是空、HTML 还是畸形 JSON,都以携带真实
status的SpreeError(code 为http_error或服务端业务码)收尾,response.json()的解析失败被.catch(() => null)完全吸收; - 响应不存在 → 才谈网络重试:只有 fetch 层异常进入网络重试分支,且受 GET/幂等键约束;
- 401 有归宿:admin 与 seller 客户端凭
instanceof SpreeError && status === 401可靠地触发onUnauthorized刷新-重放闭环,dashboard 侧再以 Promise 串行化防止令牌重复轮换。
对集成方而言,这意味着在网关故障、代理拦截、认证 token 过期这三类最常见的线上场景里,SDK 给出的错误信息是"可判断的"而不是"模糊的"——这也是该 changeset 标题中 "steady" 二字的准确注脚。
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考