Erlang 原生 HTTP 客户端:HTTP/1.1、HTTP/2、HTTP/3、WebSocket,并针对大模型 API(SSE / NDJSON 流式、附件、Bearer、429 重试、长超时)做了传输层优化。
运行时依赖:OTP 标准库 + quic(纯 Erlang QUIC/HTTP3)。不绑定 jsx 等 JSON 库,请求体由调用方编码。
| 类别 | 能力 |
|---|---|
| 协议 | HTTP/1.1、HTTP/2(ALPN)、HTTP/3(QUIC)、WebSocket |
| 请求 | 同步一次性、连接池、流式收发、显式长连接 |
| 传输 | TLS 1.2/1.3、Happy Eyeballs、Keep-Alive、H2 多路复用 |
| 代理 | HTTP CONNECT、正向代理、SOCKS5/4、HTTP_PROXY / NO_PROXY |
| 流式 | SSE、NDJSON、原始 chunk、上传 chunked / H2 DATA、写文件 sink |
| 大模型 | postStream、streamToFile、filePath 附件、bearer / apiKey、流式重试边界 |
| 其他 | Cookie、multipart、gzip/deflate、可选的一次性 brotli 解码、证书 pinning |
rebar3 compile
rebar3 eunit-include_lib("eWCli/include/wcCli.hrl"). %% 或项目里 -include("wcCli.hrl")
{ok, _} = application:ensure_all_started(eWCli).
{ok, #wcResp{status = 200, body = Body}} =
eWCli:get(<<"https://example.com/">>).本机自签证书(测试 Caddy)需要 #{verify => verify_none}。
应用层(Agent / 业务,自己拼 JSON)
eWCli
一次性:get / post / request / postMultipart
流式: postStream / streamToFile / requestStream
长连接:open + connRequest + connStream
HTTP/3:openH3 / h3Conn*
WebSocket:connectWs + wsSend
wcConn / wcH3Conn / wcPool / wcSse / wcNdjson / wcRetry / ...
响应是 #wcResp{}(version、status、reason、headers、body、trailers)。请求头是 [{Name, Value}] 列表。
连接池自动借还,跟随重定向。
eWCli:get(Url).
eWCli:get(Url, Opts).
eWCli:post(Url, Headers, Body, Opts).
eWCli:request(Method, Url, Headers, Body, Opts).同步响应默认最多攒 64MB(maxBody)。更大就调大该选项,或改用 streamToFile / withBody => false,避免把整段 body 留在堆上。
eWCli:get(Url, #{maxBody => 512 * 1024 * 1024}).
eWCli:get(Url, #{maxBody => infinity}). %% 不截断,内存仍按实际体积增长postStream 默认:format => auto(按响应 Content-Type 选 sse / ndjson / raw)、recvTimeout => infinity、maxBody => infinity、withBody => false、首包/空闲超时 300s。
Handler 返回 continue | stop(stop 会取消并得到 {error, cancelled})。
ok = eWCli:postStream(Url, Headers, JsonBody, #{
bearer => <<"sk-...">>,
json => true,
handler => fun
({headers, 200, _, _, _}) -> continue;
({sse, {data, Bin}}) -> handleToken(Bin), continue;
({sse, done}) -> continue;
(done) -> continue;
(_) -> continue
end
}).分步(自己管连接):
{ok, Conn, Ref} = eWCli:requestStream(<<"POST">>, Url, Headers, Body, Opts),
eWCli:connStream(Conn, Ref, Handler, infinity, #{format => sse}),
eWCli:release(Conn).{ok, Conn} = eWCli:open(<<"api.openai.com">>, 443, #{tls => true}),
{ok, Ref} = eWCli:connRequest(Conn, <<"POST">>, Path, Headers, Body, #{}),
eWCli:connStream(Conn, Ref, Handler, infinity, #{format => sse}),
eWCli:close(Conn).同一 Conn 上 HTTP/2 可并发多路请求。
{ok, Conn, Ref} = eWCli:connectWs(<<"wss://echo.example.com/ws">>),
eWCli:wsSend(Conn, text, <<"hello">>),
eWCli:close(Conn).true = eWCli:http3Available().
eWCli:get(Url, #{protocol => http3, verify => verify_none}).默认 protocol => auto:只在 TCP 上协商 HTTP/1.1 / HTTP/2,不自动升级 HTTP/3。
原因:QUIC/UDP 路径在部分 CDN 上存在「慢故障」(握手包被静默丢弃,只能等超时,
实测见过单请求 ~32s),默认档不给请求暴露这种风险。想让客户端自动升级 H3 时用
auto3:Alt-Svc 学到 h3 端点即升级,失败回退 TCP,连续 3 次失败熔断 5 分钟
(冷却期内不再尝试该主机的 H3)。显式 http3 则强制 QUIC,失败不回退、如实报错。
protocol |
行为 |
|---|---|
auto(默认) |
仅 TCP:h1 / h2(ALPN) |
auto3 |
TCP + Alt-Svc 学到 h3 时自动升级,失败回退 + 熔断冷却 |
http3 |
强制 QUIC/HTTP/3 |
库只做 HTTP 传输(认证头、流解析、重试、背压、写盘),不封装各厂商 messages / tools schema。
| 场景 | API | 要点 |
|---|---|---|
| 非流式 Chat / Embed / Tools | post/4 |
bearer、json、retry、idempotencyKey |
| SSE(OpenAI / DeepSeek / Anthropic) | postStream/4 |
Content-Type: text/event-stream 时自动 SSE |
NDJSON(Ollama /api/chat) |
postStream/4 |
*json* Content-Type 时自动 NDJSON;也可显式 format => ndjson |
| 慢思考(长 TTFT) | postStream/4 |
firstByteTimeout;吐词卡顿用 chunkIdleTimeout |
| 语音/图片下载 | streamToFile/2,3 |
边收边写文件 |
| STT / Files / 大附件 | postMultipart/3 |
{filePath, ...} 按块读盘上传 |
| 取消生成 | handler stop 或 connCancelStream/2 |
|
| Azure | post/4 |
azureApiKey |
| Gemini 等 | post/4 |
apiKey => {<<"x-goog-api-key">>, Key} |
| Realtime | connectWs/2 |
subProtocol、wsDeflate |
| 429 | retry => #{maxAttempts => 3} |
流式:2xx 且已开始收 body 后不再重试(避免重复计费) |
{ok, #wcResp{status = 200, body = RespBin}} =
eWCli:post(
<<"https://api.openai.com/v1/chat/completions">>,
[],
JsonBody, %% 调用方 jsx:encode / thoas 等
#{
bearer => ApiKey,
json => true,
recvTimeout => 120000,
retry => #{maxAttempts => 3},
idempotencyKey => <<"chat-001">>
}).ok = eWCli:postStream(
<<"https://api.openai.com/v1/chat/completions">>,
[],
JsonBody, %% 须含 "stream": true
#{
bearer => ApiKey,
json => true,
retry => #{maxAttempts => 3},
handler => fun
({sse, {data, <<"[DONE]"/utf8>>}}) -> continue;
({sse, {data, Bin}}) -> handleDelta(Bin), continue;
({sse, done}) -> continue;
(done) -> continue;
(_) -> continue
end
}).Handler 事件:
| 事件 | 含义 |
|---|---|
{headers, Status, Hs, Ver, Reason} |
响应头 |
{sse, {data, Bin}} |
SSE data:(OpenAI) |
{sse, {event, Type, Bin}} |
具名 SSE(Anthropic / Gemini) |
{sse, done} |
data: [DONE] |
{ndjson, Line} |
NDJSON 一行 |
{chunk, Bin} |
format => raw 的原始块 |
{trailers, Hs} |
trailer |
done |
响应结束 |
postStream 常见返回值:
| 结果 | 含义 |
|---|---|
ok |
流正常结束(2xx) |
{ok, #wcResp{status = 429 | 5xx}} |
收到可重试状态;配置了 retry 时内部会再试 |
{error, {streamStarted, Reason}} |
已出 2xx body 后失败,不会再重试 |
{error, cancelled} |
handler 返回了 stop |
{error, timeout} |
总超时或空闲超时 |
{error, bodyTooLarge} |
超过 maxBody(流式默认无上限) |
eWCli:post(<<"http://127.0.0.1:11434/api/chat">>, [], JsonBody,
#{json => true}).
eWCli:postStream(<<"http://127.0.0.1:11434/api/chat">>, [], JsonBody,
#{json => true, format => ndjson,
handler => fun
({ndjson, Line}) -> handleLine(Line), continue;
(done) -> continue;
(_) -> continue
end}).本地默认走 NO_PROXY 里的 localhost,不经过系统代理。
postMultipart 的 part:
| 形式 | 说明 |
|---|---|
{field, Name, Value} |
普通字段 |
{file, Name, Filename, ContentType, Data} |
内存中的文件 |
{filePath, Name, Path} |
读盘;文件名取 basename,类型 application/octet-stream |
{filePath, Name, Filename, ContentType, Path} |
读盘,指定文件名和 MIME |
含 filePath 时按块上传,不把整个文件读进内存。
eWCli:postMultipart(Url, [
{field, <<"model">>, <<"whisper-1">>},
{filePath, <<"file">>, <<"a.wav">>, <<"audio/wav">>, <<"/path/a.wav">>}
], #{bearer => Key}).
eWCli:streamToFile(TtsUrl, <<"/tmp/out.mp3">>, #{bearer => Key}).
%% 等价:sink => {file, Path},或 {fd, IoDevice}eWCli:post(AzureUrl, [], Body, #{azureApiKey => <<"your-key">>, json => true}).| 选项 | 默认 | 说明 |
|---|---|---|
connectTimeout |
15000 | 建连(ms) |
recvTimeout |
30000;postStream 为 infinity |
接收总超时 |
maxBody |
64MB;流式 / streamToFile 为 infinity |
攒 body 上限 |
maxRedirects |
5 | 0 禁用 |
usePool |
true | 连接池 |
maxConnections |
10 | 每目标最多连接 |
protocol |
auto |
auto(仅 TCP)| auto3(自动升 H3+熔断)| http3(强制) |
autoDecompress |
true | gzip / deflate / br |
verify |
OTP 默认(校验证书) | 测试自签用 verify_none |
| 选项 | 说明 |
|---|---|
bearer |
Authorization: Bearer |
azureApiKey |
api-key |
apiKey |
二进制 → api-key;或 {Header, Value} |
idempotencyKey |
Idempotency-Key |
json => true |
补 Content-Type: application/json(已有则不覆盖) |
format |
auto(postStream 默认,按 Content-Type)/ sse / ndjson / raw |
sse => true |
旧开关;与 format 同时出现时 format 优先 |
withBody |
是否把 chunk 拼进 #wcResp.body;流式默认 false |
sink |
{file, Path} 或 {fd, Fd} |
firstByteTimeout |
等响应头(ms),postStream 默认 300000 |
chunkIdleTimeout |
相邻 chunk 空闲(ms),默认 300000 |
retry |
#{maxAttempts => N, retryBaseMs => 500, retryMaxMs => 30000},尊重 Retry-After;POST/PATCH 默认只重试确定未发送成功的连接错误,状态码重试需显式 retryOnStatus |
handler |
流式回调 |
| 选项 | 说明 |
|---|---|
proxy |
#{host, port, user?, pass?} |
HTTP_PROXY / HTTPS_PROXY |
未设 proxy 时读取 |
noProxy / NO_PROXY |
未设时默认含 localhost |
socks5 / socks4 |
SOCKS |
sslOpts / cacerts / cert / key |
TLS |
pinnedCerts |
证书 pinning |
sni |
SNI |
| 模块 | 职责 |
|---|---|
eWCli |
公共门面 |
wcConn / wcH3Conn |
H1+H2 / H3 连接进程 |
wcPool / wcH3Pool |
连接池 |
wcStream |
流式接收、解压、SSE/NDJSON、sink |
wcSse / wcNdjson |
行解析 |
wcRetry |
429/5xx 与瞬时错误退避 |
wcAuth |
Basic / Bearer / api-key / Digest |
wcHttp1 / wcHttp2 / wcHttp3 |
编解码 |
wcWs |
WebSocket |
wcProxy / wcSocks |
代理 |
wcCodec |
内容编码 |
wcCookie / wcMultipart |
Cookie、multipart |
E2E / 压测打本机 Caddy,不连外网。仓库不提交 caddy.exe:从 Releases 下载后放到 test/caddy_tool/(说明)。
rebar3 eunit
cmd /c "set EWCLI_SKIP_E2E=1&& rebar3 eunit"
cmd /c "set EWCLI_BENCH=1&& set EWCLI_SKIP_E2E=1&& rebar3 eunit"
test\run-bench.cmdPowerShell 的 $env:EWCLI_BENCH=1 经常传不进 rebar3.cmd,压测请用 cmd /c 或 run-bench.cmd。
开发规范.md。src/wsCli/ 模块以 wc 为前缀。
HPACK 表等生成脚本在 tools/,需要提交。
Apache-2.0