Rust 微服务里最该认真写的,可能是 HTTP Client
服务端代码里,最容易被低估的不是 handler,而是你拿来调用别人的那个
reqwest::Client。
很多微服务问题表面看是“下游不稳定”,实际是调用方没有治理好 HTTP Client:
每个请求都 new Client
没有 connect timeout
没有总 deadline
重试不分错误类型
trace-id 没传下去
错误全变成 500这类代码平时能跑,一到下游慢、DNS 抖、连接池满,就会把问题放大。
HTTP Client 不是工具函数,它是服务间调用的基础设施。
前面 超时和取消 讲过请求预算,背压 讲过容量信号。这篇把视角放到出站调用:Rust 服务用 reqwest 调别人时,哪些东西必须一开始就设计好。
本文代码环境:
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"不要每次请求都 new Client
最常见的错误写法:
async fn fetch_user(id: u64) -> Result<String, reqwest::Error> {
let client = reqwest::Client::new();
client
.get(format!("https://api.example.com/users/{id}"))
.send()
.await?
.text()
.await
}这段代码能跑,但设计不对。
reqwest::Client 内部有连接池。你每次请求都 new 一个,相当于每次都丢掉复用连接的机会。服务间调用如果 QPS 稍高,这会变成额外建连、TLS 握手、端口占用和延迟抖动。
更合理的做法是:进程启动时创建一次,作为状态传进去。
#[derive(Clone)]
struct HttpClients {
user_api: reqwest::Client,
user_base: String,
}
impl HttpClients {
fn new(user_base: String) -> Self {
Self {
user_api: reqwest::Client::new(),
user_base,
}
}
}Client 本身是可 clone 的,clone 的是内部句柄,不是复制一整个连接池。
进程级 Client,服务级配置,请求级参数。 这个边界先定好,后面治理才有地方放。
timeout 要分层,不要只写一个数字
reqwest 可以在 builder 上设置超时:
use std::time::Duration;
fn build_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_millis(300))
.timeout(Duration::from_secs(2))
.build()
.expect("failed to build http client")
}这里至少有两层含义:
| 超时 | 保护什么 |
|---|---|
connect_timeout |
建连、DNS、TCP/TLS 握手别卡太久 |
timeout |
整个请求生命周期别无限等 |
但这还不够。
服务间调用最好还要受请求级 deadline 约束。比如入口只剩 200ms,就不该发一个最多等 2 秒的下游请求。
use std::time::Duration;
use tokio::time;
async fn with_budget<T>(
budget: Duration,
fut: impl std::future::Future<Output = T>,
) -> Result<T, &'static str> {
time::timeout(budget, fut)
.await
.map_err(|_| "deadline exceeded")
}Client builder 上的 timeout 是兜底,业务请求里的 budget 才是当前链路的真实预算。
固定超时保护资源,请求预算保护用户体验。
错误不能只分 success 和 failed
HTTP 调用最忌讳把错误压成一个字符串。
#[derive(Debug)]
enum ClientError {
Timeout,
Transport,
Status(reqwest::StatusCode),
Decode,
}分类以后,调用方才能做正确决策:
fn classify_error(err: reqwest::Error) -> ClientError {
if err.is_timeout() {
ClientError::Timeout
} else if err.is_decode() {
ClientError::Decode
} else if let Some(status) = err.status() {
ClientError::Status(status)
} else {
ClientError::Transport
}
}这不是洁癖。超时、连接断开、404、500、JSON 解码失败,处理策略完全不同。
| 错误 | 常见策略 |
|---|---|
| Timeout | 可重试,但受 deadline 限制 |
| Transport | 可重试,最好带退避 |
| 429/503 | 可重试,尊重 Retry-After |
| 400/401/403 | 不重试 |
| 404 | 看业务语义 |
| Decode | 不重试,优先报警 |
错误分类是重试、熔断、降级的前提。
重试只给幂等请求
HTTP Client 治理里,重试最容易写坏。
一个保守版本可以这样写:
async fn get_with_retry(client: &reqwest::Client, url: &str) -> Result<String, ClientError> {
for attempt in 0..3 {
match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
return resp.text().await.map_err(classify_error);
}
Ok(resp) if should_retry_status(resp.status()) && attempt < 2 => {
tokio::time::sleep(backoff(attempt)).await;
}
Ok(resp) => return Err(ClientError::Status(resp.status())),
Err(err) if err.is_timeout() && attempt < 2 => {
tokio::time::sleep(backoff(attempt)).await;
}
Err(err) => return Err(classify_error(err)),
}
}
Err(ClientError::Transport)
}辅助函数不复杂:
fn should_retry_status(status: reqwest::StatusCode) -> bool {
status.as_u16() == 429 || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
}
fn backoff(attempt: u32) -> std::time::Duration {
std::time::Duration::from_millis(50 * (1 << attempt))
}重点不是这段代码多漂亮,而是策略边界清楚:只重试短暂故障,不重试业务错误。
POST 也不是绝对不能重试,但必须有幂等键。没有幂等键的扣款、下单、发券,重试就是制造事故。
trace-id 必须往下传
服务间调用如果不传 trace-id,排查时链路会断。
一个简单做法是把 request id 显式塞进 header:
fn request_with_trace(
client: &reqwest::Client,
url: &str,
request_id: &str,
) -> reqwest::RequestBuilder {
client
.get(url)
.header("x-request-id", request_id)
}真实项目里通常会用 OpenTelemetry 注入 traceparent。但即使用最朴素的 x-request-id,也比什么都不传强。
出站调用日志至少要记录:
downstream=user-api method=GET path=/users/42 status=503 latency_ms=180 retry=1 request_id=...不要只在入口打日志。下游调用是微服务最常见的延迟来源,必须能单独看。
Client 配置要按下游分开
不要全系统一个 HTTP Client 配置打天下。
#[derive(Clone)]
struct Downstreams {
user: reqwest::Client,
payment: reqwest::Client,
}
impl Downstreams {
fn new() -> Self {
Self {
user: build_json_client(2),
payment: build_json_client(5),
}
}
}不同下游的超时、重试、连接池、认证方式都可能不同。
用户服务查询可以 500ms 超时,支付服务可能需要 3s;内部配置服务可以激进重试,发券接口就必须保守。
HTTP Client 的配置应该跟下游 SLA 绑定,而不是跟技术栈绑定。
结论
Rust 服务里的 HTTP Client 要按基础设施来设计,而不是按工具函数来写。
可以直接记这几条:
reqwest::Client进程级复用,不要每次请求创建- connect timeout 和总 timeout 都要有
- 请求级 deadline 要覆盖固定 timeout
- 错误要分类,不要全变成字符串
- 只重试幂等和可恢复错误
- trace-id / request-id 必须传下去
- 不同下游要有不同配置
结论很简单:出站 HTTP 调用是微服务稳定性的入口之一。
你治理好了它,下游慢的时候只是一个受控错误;你忽略它,下游慢的时候会变成连接堆积、请求超时、重试放大和全链路抖动。