返回文章列表

Rust 微服务里最该认真写的,可能是 HTTP Client

355·3 分钟阅读
Rust微服务

服务端代码里,最容易被低估的不是 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 调用是微服务稳定性的入口之一。

你治理好了它,下游慢的时候只是一个受控错误;你忽略它,下游慢的时候会变成连接堆积、请求超时、重试放大和全链路抖动。