Effect 的 HttpClient 超时限制的是单次请求还是含重试的总时间?

给一个会重试的 HTTP 调用加 Effect.timeout("2 seconds"),你以为是「每次请求最多 2 秒」,实际上它很可能是「所有重试加起来最多 2 秒」。两种写法在代码上只差一个括号的位置。

Effect.timeout 包在 retryTransient 外面时,限制的是含全部重试在内的总预算;想要「每次尝试最多 N 秒」,得把它放进重试里面,也就是自己控制重试。

原因是 Effect.timeout 只是一个包在 effect 外面的组合子,它限制的就是被它包住的那一整段。retryTransient 是 HttpClient 上的组合子,会重试暂时性失败(传输错误和 5xx、429 这类响应),它的基本用法见Effect 里怎么给偶发失败的调用加重试?。timeout 和 retry 谁包着谁的一般性讨论,见Effect 里 timeout 和 retry 谁包着谁?。下面用一个具体例子把差别跑出来。

外层 timeout 实际会怎么截断重试?

我用一个总是返回 503 的假 client 验证过。HttpClient.make 接受一个函数返回响应,这里每次调用都计数并返回 503:

// file: order.ts
import { Effect, Schedule } from "effect"
import { HttpClient, HttpClientResponse } from "effect/http"

let calls = 0
const base = HttpClient.make((request) => {
  calls++
  return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("x", { status: 503 })))
})

const client = base.pipe(
  HttpClient.filterStatusOk,
  HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 3 })
)

Effect.gen(function*() {
  const exit = yield* Effect.exit(client.get("http://x/").pipe(Effect.timeout(250)))
  console.log(calls, exit._tag)
}).pipe(Effect.runPromise)

退避从 100ms 开始,第二次请求在约 100ms 发出,第三次要等到约 300ms,而总超时 250ms 先到,结果是只发了 2 次请求,失败是 TimeoutError。retryTransient 本来配了 3 次重试,但没有机会用完。

总预算和单次限制,默认该选哪个?

默认用总预算。它对应上游调用方真正等待的时间,也不会因为重试次数调大而悄悄放大最坏延迟:重试次数从 3 改成 10,调用方最长等待仍然是你写的那个数。

需要每次尝试的限制时,要显式写出来并在注释里说明,因为这两种写法在代码上只差一个括号的位置,读代码的人很容易看错。

超时之后失败长什么样?

在上面的例子里,失败是 TimeoutError,它有自己的 _tag,可以和 HttpClientError、SchemaError 一起用 catchTags 分流。一个完整的出站服务怎么把这三类错误折叠成领域错误,见Effect 的 HttpClient 怎么把状态码、重试和超时放进错误类型?。

版本是 effect 4.0.5。上面的假 client 例子(总返回 503、250ms 总超时,得到 2 次请求和 TimeoutError)是用这个版本在本机跑过的;「每次尝试单独限时」只说明了位置要换,我没有为它写出完整代码,也没有跑过。effect/http 的文档标注为 unstable,小版本之间 API 可能调整。