Learning
VOL. XIII · NO. 21 · Elixir · 01 JAN 1970

Finch HTTP 客户端:timeout / 业务错误 / SSE

Elixir 编程 · 01 JAN 1970 · 6 min read · 1,090 words
· · ·

Finch 是 Elixir 生态里基于 Mint / :httpc 的 HTTP 客户端。它走 BEAM 进程模型,能复用连接、能做 SSE、能精确控制超时和错误语义。

本课目标
学完后,你应该能用 Finch 发出同步与并发请求;区分 connect_timeout、request_timeout、pool_timeout;设计业务错误模型(4xx vs 5xx vs 网络错误);用 Finch.stream/4 处理 SSE。

一、为什么不用 HTTPoison

HTTPoison 长期占据 Elixir HTTP 客户端生态,但它基于 :hackney,是个 NIF 库,并发模型与 BEAM 不对齐。Finch 是云原生 Elixir 团队推荐的替代品:

  • 基于 Mint(纯 Elixir)或 :httpc(Erlang)适配器。
  • 连接池 由 Supervisor 管理,自动复用 TCP/TLS。
  • 可取消:每次请求是独立 Task,可以异步取消。
  • SSE 支持Finch.stream/4 流式响应。

二、启动 Finch 实例

# config/config.exs
config :my_app, :finch, [
  MyApp.Finch,
  pools: %{
    "https://api.example.com" => [size: 10, max_connections: 50],
    "https://internal.example.com" => [size: 5, max_connections: 10]
  },
  defaults: [connect_timeout: 1_000, request_timeout: 5_000]
]

# application.ex
children = [
  {Finch, name: MyApp.Finch}
]

每个 Finch 实例是一个 supervisor,包含若干 connection pool。pool 按 host 隔离,让热点域名不抢冷门域名的连接。

实例名 vs 池
可以启动多个 Finch 实例(按业务域 / 按租户),但对单 host 的所有 pool 通常合并成一个实例即可。

三、同步请求:request/3

def get_user(id) do
  Finch.build(:get, "https://api.example.com/users/\#{id}")
  |> Finch.request(MyApp.Finch)
  |> case do
    {:ok, %Finch.Response{status: 200, body: body}} ->
      {:ok, Jason.decode!(body)}

    {:ok, %Finch.Response{status: 404}} ->
      {:error, :not_found}

    {:ok, %Finch.Response{status: s}} when s >= 500 ->
      {:error, {:upstream, s}}

    {:error, %Mint.TransportError{reason: reason}} ->
      {:error, {:network, reason}}
  end
end

注意几个返回分支:

  • {:ok, %Finch.Response{}}:HTTP 层成功了,但业务层可能失败(4xx/5xx)。
  • {:error, %Mint.TransportError{}}:TCP/TLS 失败,连接层错误。
  • {:error, %Mint.HTTPError{}}:HTTP 协议错误(解析失败、连接超时)。

四、超时三件套

Finch 把超时分成三段,每段都有明确含义:

超时含义默认值
connect_timeout建立 TCP/TLS 连接5s
request_timeout从请求发起到完整响应无(按需设置)
pool_timeout从 pool 里拿连接5s

单独请求里覆盖:

Finch.build(:get, url)
|> Finch.request(MyApp.Finch, receive_timeout: 2_000, pool_timeout: 500)
pool_timeout ≠ 业务超时
pool 排队时间应该 远小于 业务超时。如果连接池被占满,应该快速失败、上层重试,而不是阻塞用户。

五、业务错误模型

调用外部 API 时,HTTP 状态码只是开始。要把上游错误映射到业务错误:

defmodule MyApp.External do
  @type error ::
          {:network, term()}
          | {:upstream, 500..599, body :: term()}
          | {:client, 400..499, body :: term()}
          | {:decode, term()}

  @spec call(Finch.Request.t()) :: {:ok, term()} | {:error, error()}
  def call(req) do
    case Finch.request(req, MyApp.Finch, receive_timeout: 3_000) do
      {:ok, %Finch.Response{status: 200, body: body}} ->
        case Jason.decode(body) do
          {:ok, decoded} -> {:ok, decoded}
          {:error, reason} -> {:error, {:decode, reason}}
        end

      {:ok, %Finch.Response{status: s, body: body}} when s in 400..499 ->
        {:error, {:client, s, safe_decode(body)}}

      {:ok, %Finch.Response{status: s, body: body}} when s in 500..599 ->
        {:error, {:upstream, s, safe_decode(body)}}

      {:error, %Mint.TransportError{reason: reason}} ->
        {:error, {:network, reason}}
    end
  end

  defp safe_decode(b), do: with {:ok, v} <- Jason.decode(b), do: v
end

关键思想:

  • 4xx 通常是请求错误,重试不会变好,记日志返回。
  • 5xx 是上游问题,可能重试,但要做退避(exponential backoff)。
  • 网络错误:TLS、TCP、DNS 都可能瞬时失败,有限重试

六、并发:async + Task.await_many

def fetch_many(ids) do
  Task.async_stream(ids, fn id ->
    MyApp.External.get_user(id)
  end, max_concurrency: 10, timeout: 10_000, on_timeout: :kill_task)
  |> Enum.to_list()
end
max_concurrency ≠ 池大小
并发数应该 ≤ 对应 pool 的 size。否则新请求会卡在 pool_timeout

七、SSE:流式响应

LLM、事件推送、监控面板常用 Server-Sent Events。Finch 把响应体当作可枚举流:

def stream_events(url, on_event) do
  Finch.build(:get, url, headers: [{"accept", "text/event-stream"}])
  |> Finch.stream(MyApp.Finch, receive_timeout: :infinity, body_chunks: :infinite)
  |> Stream.flat_map(fn
    {:status, _status} -> []
    {:headers, _} -> []
    {:data, data} -> [data]
    {:done, _} -> []
  end)
  |> Stream.each(&on_event.(&1))
  |> Stream.run()
end

关键点:

  • receive_timeout: :infinity:长连接禁用默认超时。
  • body_chunks: :infinite:不限制 chunk 累计。
  • Finch.stream/4 返回一个 enumerable,每项是 {:status, _} / {:headers, _} / {:data, _} / {:done, _}
  • 取消时调用 Task.shutdown(task, :brutal_kill) 即可中止整个流。

八、可观测性

Finch 默认带 telemetry 事件:

  • [:finch, :request, :start] / [:finch, :request, :stop] / [:finch, :request, :error]
  • 元数据包含 result:ok | :error)、statusdurationpoolhost

把这些事件转发到 Prometheus / OpenTelemetry,就能看到 P50 / P95 / 错误率。

九、生产清单

  • 每个上游域名一个 pool;按 SLA 决定 pool size。
  • 业务超时 < 用户感知超时 < 进程超时。
  • 4xx 不要重试;5xx 与网络错误用指数退避重试,最多 3 次。
  • SSE 一定要 receive_timeout: :infinity
  • 异步任务用 on_timeout: :kill_task,防止泄漏。
  • 记录 pool_timeout 错误:如果频繁出现,说明连接池不够。

十、测验

1

Finch 推荐的底层适配器是?

2

pool 与 host 的推荐关系?

3

request_timeout 控制的是?

4

pool_timeout 应该?

5

4xx 响应通常意味着?

6

5xx 响应的推荐策略?

7

Finch.stream/4 接收超时应该?

8

Task.async_streamon_timeout: :kill_task 作用?

9

max_concurrency 与 pool size 的关系?

10

Finch 默认发出的 telemetry 事件前缀是?

**下一步:**Finch + Plug + Ecto 三件套齐了,下一阶段可以进入真实项目:写一个最小可用的 API 服务,跑 mix release,部署到一台机器。