从类型泄漏到类型安全:effect-smol 修复 `HttpClient.retryTransient` 自动补全问题的工程实践
发布时间:2026/9/14 11:49:14 作者:尧图编辑部 阅读量:1,286

从类型泄漏到类型安全effect-smol 修复HttpClient.retryTransient自动补全问题的工程实践【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code导读retryTransient是 effect-smolEffect 库中用于处理网络瞬时故障超时、限流、5xx的高频 API。本篇文章以该 API 的一个真实类型缺陷修复为主线剖析其背后的设计动机、Schedule联合类型如何污染 TypeScript 的自动补全与类型推断以及通过拆分重载overload这一类型层面的重构是如何在不改变运行时行为的前提下彻底解决该问题的。读完你将理解函数重载的顺序、联合类型在自动补全中的表现以及如何用类型测试守护 API 的可用性这些都是在开发高质量 TypeScript 库时不可忽视的细节。背景retryTransient的职责与演进在 effect-smol 中HttpClient.retryTransient是一个专门用于重试常见瞬时错误的 API例如速率限制、超时或网络问题。它的设计目标是让开发者能够专注于决定重试什么只重试错误errors-only、只重试瞬时响应response-only还是两者都重试errors-and-responses。它位于 packages/effect/src/unstable/http/HttpClient.tscategory error handlingsince 4.0.0与它同属一个命名空间的还有retry基于Effect.retry的通用重试。retryTransient的独特之处在于它内置了瞬时判定逻辑而不需要用户自己编写while谓词。这个 API 并非一蹴而就。在 effect-smol 的 CHANGELOG.md 中可以追溯它的演进选项重命名早期版本中控制重试范围的选项叫mode取值包含both后来被重命名为retryOn且both改为errors-and-responses。这一变更由 PR #1383 引入详见 CHANGELOG 对应条目。本次修复PR #1443 修复了retryTransient自动补全泄漏Schedule内部类型的问题正是本文要展开的核心。问题剖析{...} | Schedule联合类型如何泄漏内部类型在修复之前retryTransient的签名本质上是一个联合类型把选项对象和裸Schedule两种用法合并到了一个参数里// 修复前的示意签名 options: | { readonly retryOn?: errors-only | response-only | errors-and-responses | undefined readonly while?: Predicate.PredicateE | ES | undefined readonly schedule?: Schedule.ScheduleB, any, ES, R1 | undefined readonly times?: number | undefined } | Schedule.ScheduleB, any, ES, R1也就是说调用者可以// 用法一传入选项对象 HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 3 }) // 用法二直接传入一个 Schedule等价于省略了 retryOn 的默认语义 HttpClient.retryTransient(Schedule.exponential(100))这种一个参数两种形态的写法在运行时没有任何问题——在实现里通过Schedule.isSchedule(options)做类型收窄即可区分。但在类型层面联合类型会对 IDE 的自动补全autocomplete产生严重的副作用当你开始输入HttpClient.retryTransient({ ... })TypeScript 会把联合的每一个成员的可用属性都列出来。Schedule.ScheduleB, any, ES, R1是一个拥有大量内部成员如step、run、while、upTo等众多组合方法以及各种内部符号的复杂接口。这些内部 API 会一股脑地泄漏到自动补全面板中挤占甚至淹没真正有用的retryOn、while、schedule、times选项。更糟糕的是联合类型在某些场景下会退化为any或产生不精确的推断进一步降低类型安全性。这正是 changeset 中leakingScheduleinternals泄漏Schedule内部实现的含义。解决方案将联合拆分为独立重载修复方式非常干净把选项对象和裸 Schedule两种形态拆成两个独立的重载overload而不是塞进同一个联合类型。修复后的公开签名如下完整实现见 HttpClient.tsexport const retryTransient: { // 重载一选项对象形态含>const isOnlySchedule Schedule.isSchedule(options) const retryOn isOnlySchedule ? errors-and-responses : options.retryOn ?? errors-and-responses const schedule isOnlySchedule ? options : options.schedule const passthroughSchedule schedule Schedule.passthrough(schedule) const times isOnlySchedule ? undefined : options.times return transformResponse( self, flow( retryOn errors-only ? identity : Effect.repeat({ schedule: passthroughSchedule!, times, while: isTransientResponse }), retryOn response-only ? identity : Effect.retry({ while: isOnlySchedule || options.while undefined ? isTransientError : Predicate.or(isTransientError, options.while), schedule, times }) ) )关键点逐条解读Schedule.isSchedule运行时收窄先判断传入的是裸 Schedule 还是选项对象。裸 Schedule 的retryOn被固定为errors-and-responses与选项对象的默认值一致因此直接传 Schedule 与传{ schedule, retryOn: errors-and-responses }完全等价。Schedule.passthrough的巧用Effect.repeat需要用响应HttpClientResponse驱动重试判定而用户提供的Schedule的输出类型未必是响应类型。passthrough定义见 Schedule.ts会返回一个把输入原样作为输出的新 Schedule从而让Effect.repeat拿到真实响应来执行isTransientResponse判定。这也是文档注释里Specifying awhilepredicate allows you to consider other errors as transient, and is ignored in response-only mode在response-only模式下while被忽略的根源。isTransientError/isTransientResponse的判定规则见 HttpClient.tsconst isTransientError (error: unknown) Cause.isTimeoutError(error) || isTransientHttpError(error) const isTransientHttpError (error: unknown) Error.isHttpClientError(error) (error.reason._tag TransportError || (error.reason._tag StatusCodeError isTransientResponse(error.reason.response))) const isTransientResponse (response: HttpClientResponse.HttpClientResponse) response.status 408 || response.status 429 || response.status 500 || response.status 502 || response.status 503 || response.status 504即错误层面包括超时错误Cause.isTimeoutError、传输层错误TransportError以及携带瞬时状态码的StatusCodeError响应层面则精确覆盖 408Request Timeout、429Too Many Requests、500、502、503、504 六个状态码。这为瞬时故障给出了明确、可测试的判定标准。如何正确使用retryTransient基础用法指数退避重试官方 AI 文档示例见 ai-docs/src/50_http-client/10_basics.ts展示了最典型的组合——对 JSONPlaceholder 之类的 HTTP API 客户端统一配置重试策略const client (yield* HttpClient.HttpClient).pipe( HttpClient.mapRequest(flow( HttpClientRequest.prependUrl(https://jsonplaceholder.typicode.com), HttpClientRequest.acceptJson )), HttpClient.filterStatusOk, // 对网络问题、5xx 响应执行指数退避重试最多 3 次 HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 3 }) )参数说明选项类型说明默认值retryOnerrors-only \| response-only \| errors-and-responses控制重试的范围只重试错误 / 只重试瞬时响应 / 两者都重试errors-and-responsesscheduleSchedule.ScheduleB, Input, ES, R1重试节奏常用Schedule.exponential(ms)、Schedule.fixed等可配合upTo、while组合无需提供或直接传裸 Scheduletimesnumber最大重试次数在 schedule 之外的额外上限undefined不限制whilePredicate.PredicateE \| ES自定义视为瞬时的谓词可与内置瞬时判定做或合并response-only模式下被忽略undefined三种重试范围的语义对比// 只重试瞬时错误超时、传输错误、带瞬时状态码的 StatusCodeError client.pipe(HttpClient.retryTransient({ retryOn: errors-only, times: 2 })) // 只重试瞬时响应408/429/500/502/503/504 client.pipe(HttpClient.retryTransient({ retryOn: response-only, times: 2 })) // 默认错误与瞬时响应都重试 client.pipe(HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 2 })) // 等价写法直接传入裸 ScheduleretryOn 固定为 errors-and-responses client.pipe(HttpClient.retryTransient(Schedule.exponential(100)))完整示例自定义瞬时判定import { Effect, HttpClient, HttpClientRequest, HttpClientResponse, Schedule, Predicate } from effect // 把服务暂时不可用的自定义条件也视作瞬时错误 const retryClient client.pipe( HttpClient.retryTransient({ retryOn: errors-and-responses, schedule: Schedule.exponential(100 millis).pipe(Schedule.upTo(1 seconds)), times: 3, while: (error) error instanceof MyTransientServiceError }) )类型测试如何防止回归类型层面的修复必须用类型测试来锁定effect-smol 使用typetest目录下的编译期断言见 HttpClient.tst.ts同时守护该接受的必须接受、该拒绝的必须拒绝两个方向describe(retryTransient, () { it(should accept retryOn values, () { client.pipe(HttpClient.retryTransient({ retryOn: errors-only })) client.pipe(HttpClient.retryTransient({ retryOn: response-only })) client.pipe(HttpClient.retryTransient({ retryOn: errors-and-responses })) }) it(should reject mode option, () { client.pipe( // ts-expect-error mode does not exist in type { readonly retryOn?: ... } HttpClient.retryTransient({ mode: errors-only }) ) }) it(should reject both retry value, () { client.pipe( // ts-expect-error Type both is not assignable to type errors-only | response-only | errors-and-responses | undefined HttpClient.retryTransient({ retryOn: both }) ) }) })这套测试清晰地记录了 API 的契约边界正向约束三个合法的retryOn值必须全部通过类型检查负向约束被废弃的mode选项旧 API 遗留必须报错both这个旧值也必须被拒绝——这确保了向新命名迁移后老代码不会悄悄通过编译。运行时行为则由 HttpClient.test.ts 中的 effect 测试守护describe(retryTransient, () { it.effect(retries transient responses with retryOn errors-and-responses, () Effect.gen(function*() { const { attempts, client } yield* makeStatusClient(503) const retryClient client.pipe(HttpClient.retryTransient({ retryOn: errors-and-responses, times: 2 })) yield* retryClient.get(http://test/).pipe(Effect.ignore) strictEqual(yield* Ref.get(attempts), 3) // 初始 1 次 重试 2 次 })) it.effect(does not retry transient responses with retryOn errors-only, () Effect.gen(function*() { const { attempts, client } yield* makeStatusClient(503) const retryClient client.pipe(HttpClient.retryTransient({ retryOn: errors-only, times: 2 })) yield* retryClient.get(http://test/).pipe(Effect.ignore) strictEqual(yield* Ref.get(attempts), 1) // 503 属于瞬时响应errors-only 不重试 })) })这两条用例分别验证了瞬时响应会被重试503 最终请求 3 次与errors-only 模式不重试瞬时响应仅 1 次与类型测试互为补充共同锁定了修复前后的行为一致性。给库作者与使用者的启示这次修复虽然只改动了一个函数的重载结构却浓缩了几条值得借鉴的工程经验联合类型的补全污染是真实问题。当一个参数接受选项对象 | 复杂接口时复杂接口的内部成员方法、符号、辅助类型会直接涌入 IDE 自动补全破坏开发者体验。拆分重载是解决这类问题的标准手段让每个重载只暴露自己关心的形态retryOn等真实选项才能获得干净的补全与更精确的推断。类型修复不应改变运行时语义。本次修复通过dual(2, ...)保持 contenteditable="false">【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考