HTTP 客户端实战:httpx/重试/限速/连接池/中间件设计

文章目录

    • [1. 从 requests 到 httpx:为什么需要升级](#1. 从 requests 到 httpx:为什么需要升级)
    • [2. 客户端实例与连接池:一次创建,多次复用](#2. 客户端实例与连接池:一次创建,多次复用)
    • [3. 超时控制三件套:connect / read / write](#3. 超时控制三件套:connect / read / write)
    • [4. 重试策略设计:指数退避与幂等性保证](#4. 重试策略设计:指数退避与幂等性保证)
      • [4.1 指数退避 + 随机抖动](#4.1 指数退避 + 随机抖动)
      • [4.2 可重试条件](#4.2 可重试条件)
    • [5. 限速与并发控制:双重保护](#5. 限速与并发控制:双重保护)
      • [5.1 连接池级别限流](#5.1 连接池级别限流)
      • [5.2 应用层速率限制](#5.2 应用层速率限制)
    • [6. 中间件模式:请求/响应拦截器](#6. 中间件模式:请求/响应拦截器)
    • [7. 断路器模式:防止连锁故障](#7. 断路器模式:防止连锁故障)
    • [8. 异步并发:asyncio.gather 的正确使用](#8. 异步并发:asyncio.gather 的正确使用)
    • [9. 实战:生产级 HTTP 客户端封装](#9. 实战:生产级 HTTP 客户端封装)
    • 总结

1. 从 requests 到 httpx:为什么需要升级

requests.get(url) 是 Python 开发者的肌肉记忆。一行代码发送 HTTP 请求、解析 JSON、处理响应------在原型阶段和低并发场景下,这套 API 足够优雅。但当服务需要每秒调用数十个外部 API、响应延迟直接关联用户体验、一次网络抖动就能触发超时连锁反应时,requests 的三个短板就会暴露:

短版一:没有原生 async 支持。 requests 是纯同步库,在 asyncio 事件循环中调用会阻塞整个线程。虽然可以用 loop.run_in_executor() 将同步调用扔进线程池,但线程切换的上下文开销和 GIL 竞争让高并发场景下的性能大打折扣。

短板二:没有连接池复用机制。 每次调用 requests.get() 都会创建新的 TCP 连接(底层 urllib3 有连接池,但 requests.Session() 的方式不够直观,大多数开发者直接用快捷函数)。在微服务间高频通信的场景中,TCP 三次握手的开销累积起来相当可观。

短板三:超时控制粒度不够。 requests.get(url, timeout=5) 只有一个笼统的超时参数,无法区分"连接超时"和"读取超时"。如果一个请求 TCP 握手用了 4.9 秒、数据读取又用了 20 秒,它仍然是"合法"的------但实际体验已经崩溃。

httpx 解决这三个问题的方式是原生的:async 客户端、显式连接池管理、三级超时控制。以下是三者的核心特性对比:

特性 requests aiohttp httpx
同步 API
异步 API
HTTP/2
连接池 隐式(Session) 内置 内置 + 可配置
超时粒度 单一 timeout 单一 timeout connect/read/write 三级
API 风格 经典 独立语法 兼容 requests 风格
类型提示 社区维护 内置 内置

选择标准可以简化为:新项目默认用 httpx;需要极致性能的异步爬虫考虑 aiohttp;维护老项目且迁移成本过高时保留 requests


2. 客户端实例与连接池:一次创建,多次复用

httpx 提供了两种使用方式:快捷函数和客户端实例。两者的差异在单次请求中微乎其微,但在批量请求中差距巨大。

python 复制代码
import httpx
import time

# 方式一:快捷函数------每次新建连接
def with_shortcut(urls: list[str]):
    results = []
    for url in urls:
        r = httpx.get(url)  # 每次都是新的 TCP 连接
        results.append(r.json())
    return results

# 方式二:客户端实例------复用连接池
def with_client(urls: list[str]):
    results = []
    with httpx.Client() as client:  # 创建一个连接池
        for url in urls:
            r = client.get(url)     # 复用同一批 TCP 连接
            results.append(r.json())
    return results

向同一个 API 服务器发起 10 次请求时,快捷函数会经历 10 次完整的 TCP 握手(SYN → SYN-ACK → ACK),每次握手约耗时 1-3ms(同机房)到 50-200ms(跨地域)。客户端实例则只需第一次请求建立连接,后续 9 次直接复用------这一差异在高频调用场景下足以省下数百毫秒。
#mermaid-svg-p78CkdKGfevRA24E{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-p78CkdKGfevRA24E .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-p78CkdKGfevRA24E .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-p78CkdKGfevRA24E .error-icon{fill:#552222;}#mermaid-svg-p78CkdKGfevRA24E .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-p78CkdKGfevRA24E .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-p78CkdKGfevRA24E .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-p78CkdKGfevRA24E .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-p78CkdKGfevRA24E .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-p78CkdKGfevRA24E .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-p78CkdKGfevRA24E .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-p78CkdKGfevRA24E .marker{fill:#333333;stroke:#333333;}#mermaid-svg-p78CkdKGfevRA24E .marker.cross{stroke:#333333;}#mermaid-svg-p78CkdKGfevRA24E svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-p78CkdKGfevRA24E p{margin:0;}#mermaid-svg-p78CkdKGfevRA24E .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-p78CkdKGfevRA24E .cluster-label text{fill:#333;}#mermaid-svg-p78CkdKGfevRA24E .cluster-label span{color:#333;}#mermaid-svg-p78CkdKGfevRA24E .cluster-label span p{background-color:transparent;}#mermaid-svg-p78CkdKGfevRA24E .label text,#mermaid-svg-p78CkdKGfevRA24E span{fill:#333;color:#333;}#mermaid-svg-p78CkdKGfevRA24E .node rect,#mermaid-svg-p78CkdKGfevRA24E .node circle,#mermaid-svg-p78CkdKGfevRA24E .node ellipse,#mermaid-svg-p78CkdKGfevRA24E .node polygon,#mermaid-svg-p78CkdKGfevRA24E .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-p78CkdKGfevRA24E .rough-node .label text,#mermaid-svg-p78CkdKGfevRA24E .node .label text,#mermaid-svg-p78CkdKGfevRA24E .image-shape .label,#mermaid-svg-p78CkdKGfevRA24E .icon-shape .label{text-anchor:middle;}#mermaid-svg-p78CkdKGfevRA24E .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-p78CkdKGfevRA24E .rough-node .label,#mermaid-svg-p78CkdKGfevRA24E .node .label,#mermaid-svg-p78CkdKGfevRA24E .image-shape .label,#mermaid-svg-p78CkdKGfevRA24E .icon-shape .label{text-align:center;}#mermaid-svg-p78CkdKGfevRA24E .node.clickable{cursor:pointer;}#mermaid-svg-p78CkdKGfevRA24E .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-p78CkdKGfevRA24E .arrowheadPath{fill:#333333;}#mermaid-svg-p78CkdKGfevRA24E .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-p78CkdKGfevRA24E .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-p78CkdKGfevRA24E .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p78CkdKGfevRA24E .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-p78CkdKGfevRA24E .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p78CkdKGfevRA24E .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-p78CkdKGfevRA24E .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-p78CkdKGfevRA24E .cluster text{fill:#333;}#mermaid-svg-p78CkdKGfevRA24E .cluster span{color:#333;}#mermaid-svg-p78CkdKGfevRA24E div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-p78CkdKGfevRA24E .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-p78CkdKGfevRA24E rect.text{fill:none;stroke-width:0;}#mermaid-svg-p78CkdKGfevRA24E .icon-shape,#mermaid-svg-p78CkdKGfevRA24E .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p78CkdKGfevRA24E .icon-shape p,#mermaid-svg-p78CkdKGfevRA24E .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-p78CkdKGfevRA24E .icon-shape .label rect,#mermaid-svg-p78CkdKGfevRA24E .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p78CkdKGfevRA24E .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-p78CkdKGfevRA24E .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-p78CkdKGfevRA24E :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端实例
请求1
TCP握手
响应1
请求2
响应2

复用连接
快捷函数
请求1
TCP握手
响应1
请求2
TCP握手
响应2

在 FastAPI 应用中,推荐的模式是在应用启动时创建一个全局的 httpx.AsyncClient,通过依赖注入传递给路由函数:

python 复制代码
from contextlib import asynccontextmanager
from httpx import AsyncClient

client: AsyncClient | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global client
    client = AsyncClient(
        limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
        timeout=httpx.Timeout(10.0, connect=3.0),
    )
    yield
    await client.aclose()

async def get_http_client() -> AsyncClient:
    assert client is not None
    return client

@app.get("/proxy")
async def proxy(request: Request, http: AsyncClient = Depends(get_http_client)):
    response = await http.get("https://api.external.com/data")
    return response.json()

全局单例 AsyncClient 的使用方式与依赖注入机制的结合,确保了整个应用生命周期内连接池的一致性。关于 FastAPI 依赖注入的缓存机制,在本系列第一篇文章中有详细分析。


3. 超时控制三件套:connect / read / write

httpx 的超时模型将一次 HTTP 请求拆分为三个独立阶段,每个阶段有各自的超时阈值:

python 复制代码
import httpx

timeout = httpx.Timeout(
    connect=3.0,    # TCP 握手超时
    read=30.0,      # 等待响应数据超时
    write=10.0,     # 发送请求体超时
    pool=5.0,       # 从连接池获取连接超时
)

with httpx.Client(timeout=timeout) as client:
    response = client.get("https://api.example.com/data")

四个参数各司其职:

  • connect(推荐 3s):限制 TCP 三次握手的最大耗时。如果服务器宕机,连接尝试会立即失败而非等待操作系统默认的 75 秒超时。这个值不宜设得太大------如果一个服务 3 秒都连不上,继续等待的意义不大。

  • read(推荐 10-30s):限制从发送请求到接收完整响应的最大耗时。这是最常用的超时参数。对于数据导出等慢接口可以适当放大到 60s,但不要用超时来掩盖慢查询------慢查询应该通过索引优化解决。

  • write(推荐 10s) :限制将请求体写入 Socket 缓冲区的最大耗时,仅在 POST/PUT/PATCH 的大请求体场景下有意义。

  • pool(推荐 2-5s) :限制从连接池获取可用连接的最大等待时间。当连接池耗尽时(所有连接都在使用中),新的请求会等待而不是立即失败,pool 参数限制了等待的上限。

httpx 的超时异常准确度值得额外说明。与 requests 不同,httpx 的超时异常类型细分为 ConnectTimeoutReadTimeoutWriteTimeoutPoolTimeout------这意味着异常处理代码可以精确地区分不同的超时场景:

python 复制代码
try:
    response = await client.get(url)
except httpx.ConnectTimeout:
    # TCP 连接超时:目标服务可能宕机,不需要重试
    raise ServiceUnavailableError(f"无法连接到 {url}")
except httpx.ReadTimeout:
    # 读取响应超时:可能是慢查询或网络抖动,考虑重试
    raise ReadTimeoutError(f"读取 {url} 响应超时")
except httpx.PoolTimeout:
    # 连接池耗尽:当前并发请求过多,需要限流或扩容
    raise TooManyRequestsError("连接池已满")

4. 重试策略设计:指数退避与幂等性保证

重试不是"失败了就再来一次"------没有策略的重试会把瞬时的网络抖动放大为雪崩效应。

4.1 指数退避 + 随机抖动

python 复制代码
import asyncio
import random
import httpx

async def fetch_with_retry(
    client: httpx.AsyncClient,
    url: str,
    max_retries: int = 3,
    base_delay: float = 1.0,
):
    for attempt in range(max_retries + 1):
        try:
            response = await client.get(url)
            response.raise_for_status()
            return response
        except (httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
            if attempt == max_retries:
                raise
            # 指数退避:1s → 2s → 4s,加上随机抖动
            delay = base_delay * (2 ** attempt)
            jitter = random.uniform(0, delay * 0.3)
            await asyncio.sleep(delay + jitter)

随机抖动(jitter)是重试策略中最容易被忽略的部分。假如 100 个并发的客户端同时因上游服务短暂过载而失败,如果都按照"等待固定 1 秒后重试",100 个请求会在同一毫秒再次涌入------服务刚恢复又被冲垮。随机抖动将重试时间分散到 1 个窗口内,避免了"重试风暴"。

4.2 可重试条件

并非所有失败都值得重试。重试的核心前提是操作具有幂等性------无论执行一次还是多次,结果相同:

HTTP 方法 幂等性 默认重试
GET 天然幂等
PUT 天然幂等
DELETE 天然幂等
HEAD 天然幂等
POST 非幂等 ❌(需业务确认)
PATCH 非幂等 ❌(需业务确认)

状态码维度,以下状态码意味着"服务端暂时无法处理,稍后可能恢复":

状态码 含义 重试建议
429 速率限制 ✅ 根据 Retry-After 头等待
502 网关错误
503 服务不可用
504 网关超时
408 请求超时 ✅(仅 GET)
500 内部错误 ⚠️ 谨慎重试(可能是业务逻辑错误)

基于 httpxtenacity 的组合可以构建生产级的重试策略:

python 复制代码
from tenacity import (
    retry, stop_after_attempt, wait_exponential,
    retry_if_exception_type, before_sleep_log,
)
import logging

logger = logging.getLogger(__name__)

RETRYABLE_STATUS = {429, 502, 503, 504}

def is_retryable(response):
    return response.status_code in RETRYABLE_STATUS

@retry(
    retry=retry_if_exception_type((httpx.ReadTimeout, httpx.ConnectError)),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    before_sleep=before_sleep_log(logger, logging.WARNING),
)
async def fetch_external_api(client, url):
    response = await client.get(url)
    if is_retryable(response):
        raise httpx.HTTPStatusError(
            f"{response.status_code} {url}",
            request=response.request,
            response=response,
        )
    response.raise_for_status()
    return response

5. 限速与并发控制:双重保护

对外部 API 的调用必须有速率上限------不是防止自己的服务崩溃,而是避免触发上游的限流策略。

5.1 连接池级别限流

python 复制代码
limits = httpx.Limits(
    max_connections=20,        # 全局最大连接数
    max_keepalive_connections=10,  # 保持活跃的空闲连接数
    keepalive_expiry=30.0,     # 空闲连接保持时间
)

client = httpx.AsyncClient(limits=limits)

max_connections 是硬上限------当连接池的 20 个连接都在使用中时,第 21 个请求会排队等待(受 pool 超时限制),而不是创建新连接。这直接限制了对上游服务的并发调用数。

5.2 应用层速率限制

连接池限制的是"同时进行的请求数",而速率限制关心的是"每秒发起的请求数"。两者的关系类似于高速公路的"车道数"和"收费站发卡速度":

python 复制代码
import asyncio
import time

class RateLimiter:
    def __init__(self, max_calls: int, period: float = 1.0):
        self.max_calls = max_calls
        self.period = period
        self.calls: list[float] = []

    async def acquire(self):
        now = time.monotonic()
        # 移除 period 秒之前的记录
        self.calls = [t for t in self.calls if now - t < self.period]
        if len(self.calls) >= self.max_calls:
            wait_time = self.calls[0] + self.period - now
            await asyncio.sleep(wait_time)
        self.calls.append(time.monotonic())

# 使用:每秒最多 5 个请求
limiter = RateLimiter(max_calls=5)

async def rate_limited_fetch(client, url):
    await limiter.acquire()
    return await client.get(url)

在真实项目中,asyncio.Semaphore + RateLimiter 的组合可以提供更精细的控制:Semaphore 限制并发数(防止连接池打满),RateLimiter 限制 QPS(防止触发上游限流)。


6. 中间件模式:请求/响应拦截器

httpxevent_hooks 机制允许在请求发送前和响应接收后插入逻辑------这本质上就是中间件模式:

python 复制代码
import uuid
import time

def log_request(request: httpx.Request):
    request.headers["X-Request-ID"] = str(uuid.uuid4())[:8]
    request.headers["X-Request-Start"] = str(time.monotonic())

def log_response(response: httpx.Response):
    request = response.request
    elapsed = time.monotonic() - float(request.headers["X-Request-Start"])
    logger.info(
        "%s %s -> %d (%.2fs)",
        request.method, request.url, response.status_code, elapsed,
    )

client = httpx.Client(event_hooks={
    "request": [log_request],
    "response": [log_response],
})

event_hooks 的值是一组回调函数列表(允许注册多个),这为构建可组合的中间件提供了基础。以下是一个更完整的中间件链------认证 Token 自动续期:

python 复制代码
class AuthMiddleware:
    def __init__(self, token_url: str, client_id: str, client_secret: str):
        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self._token: str | None = None
        self._expires_at: float = 0.0

    async def __call__(self, request: httpx.Request):
        if time.monotonic() > self._expires_at - 60:
            await self._refresh_token()
        request.headers["Authorization"] = f"Bearer {self._token}"

    async def _refresh_token(self):
        async with httpx.AsyncClient() as client:
            resp = await client.post(self.token_url, data={
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "grant_type": "client_credentials",
            })
            data = resp.json()
            self._token = data["access_token"]
            self._expires_at = time.monotonic() + data.get("expires_in", 3600)

AuthMiddleware 是一个可调用对象,它在每次请求前检查 Token 是否即将过期(提前 60 秒刷新),如果过期则先获取新 Token 再发送原始请求。这个模式的关键在于"无侵入"------调用方不需要感知 Token 的存在,和正常使用 httpx 一样。


7. 断路器模式:防止连锁故障

当上游服务持续不可用时,不断重试不仅浪费资源,还可能把上游从"暂时不可用"拖到"彻底崩溃"。断路器模式在微服务中相当于电路中的保险丝------检测到连续失败后,直接切断请求通路,隔一段时间再探测试探。
#mermaid-svg-NoMBUi3Z3lqtsSQv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NoMBUi3Z3lqtsSQv .error-icon{fill:#552222;}#mermaid-svg-NoMBUi3Z3lqtsSQv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NoMBUi3Z3lqtsSQv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .marker.cross{stroke:#333333;}#mermaid-svg-NoMBUi3Z3lqtsSQv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NoMBUi3Z3lqtsSQv p{margin:0;}#mermaid-svg-NoMBUi3Z3lqtsSQv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .cluster-label text{fill:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .cluster-label span{color:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .cluster-label span p{background-color:transparent;}#mermaid-svg-NoMBUi3Z3lqtsSQv .label text,#mermaid-svg-NoMBUi3Z3lqtsSQv span{fill:#333;color:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .node rect,#mermaid-svg-NoMBUi3Z3lqtsSQv .node circle,#mermaid-svg-NoMBUi3Z3lqtsSQv .node ellipse,#mermaid-svg-NoMBUi3Z3lqtsSQv .node polygon,#mermaid-svg-NoMBUi3Z3lqtsSQv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .rough-node .label text,#mermaid-svg-NoMBUi3Z3lqtsSQv .node .label text,#mermaid-svg-NoMBUi3Z3lqtsSQv .image-shape .label,#mermaid-svg-NoMBUi3Z3lqtsSQv .icon-shape .label{text-anchor:middle;}#mermaid-svg-NoMBUi3Z3lqtsSQv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .rough-node .label,#mermaid-svg-NoMBUi3Z3lqtsSQv .node .label,#mermaid-svg-NoMBUi3Z3lqtsSQv .image-shape .label,#mermaid-svg-NoMBUi3Z3lqtsSQv .icon-shape .label{text-align:center;}#mermaid-svg-NoMBUi3Z3lqtsSQv .node.clickable{cursor:pointer;}#mermaid-svg-NoMBUi3Z3lqtsSQv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .arrowheadPath{fill:#333333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NoMBUi3Z3lqtsSQv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NoMBUi3Z3lqtsSQv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NoMBUi3Z3lqtsSQv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NoMBUi3Z3lqtsSQv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .cluster text{fill:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv .cluster span{color:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NoMBUi3Z3lqtsSQv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NoMBUi3Z3lqtsSQv rect.text{fill:none;stroke-width:0;}#mermaid-svg-NoMBUi3Z3lqtsSQv .icon-shape,#mermaid-svg-NoMBUi3Z3lqtsSQv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NoMBUi3Z3lqtsSQv .icon-shape p,#mermaid-svg-NoMBUi3Z3lqtsSQv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NoMBUi3Z3lqtsSQv .icon-shape .label rect,#mermaid-svg-NoMBUi3Z3lqtsSQv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NoMBUi3Z3lqtsSQv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NoMBUi3Z3lqtsSQv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NoMBUi3Z3lqtsSQv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连续失败 ≥ 阈值
冷却时间到
探测成功
探测失败
CLOSED 正常通行
OPEN 熔断拒绝
HALF_OPEN 探测

断路器的核心状态机和实现如下:

python 复制代码
import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"           # 正常通行
    OPEN = "open"               # 熔断,直接拒绝
    HALF_OPEN = "half_open"     # 半开,允许少量探测

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 30.0,
        half_open_max: int = 3,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_max = half_open_max

        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.last_failure_time = 0.0
        self.half_open_attempts = 0

    async def call(self, coro_factory, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if time.monotonic() - self.last_failure_time >= self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
                self.half_open_attempts = 0
            else:
                raise CircuitBreakerOpenError("断路器已熔断")

        if self.state == CircuitState.HALF_OPEN:
            if self.half_open_attempts >= self.half_open_max:
                raise CircuitBreakerOpenError("半开状态请求数已满")
            self.half_open_attempts += 1

        try:
            result = await coro_factory(*args, **kwargs)
            # 成功 → 恢复
            self.state = CircuitState.CLOSED
            self.failure_count = 0
            return result
        except Exception:
            self.failure_count += 1
            self.last_failure_time = time.monotonic()
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
            raise

使用方式:

python 复制代码
breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)

async def safe_fetch(client, url):
    return await breaker.call(client.get, url)

断路器与重试的关系需要仔细设计------重试在断路器内部,还是在断路器外部?推荐的做法是"重试在外,断路器在内":重试负责处理瞬时故障(网络抖动),断路器负责处理持续故障(服务宕机)。重试失败累计到阈值后,断路器熔断;熔断期间,重试也不会执行。


8. 异步并发:asyncio.gather 的正确使用

python 复制代码
import asyncio

async def fetch_multiple(client: httpx.AsyncClient, urls: list[str]):
    async with client:
        tasks = [client.get(url) for url in urls]
        responses = await asyncio.gather(*tasks, return_exceptions=True)

    results = []
    for url, resp in zip(urls, responses):
        if isinstance(resp, Exception):
            results.append({"url": url, "error": str(resp)})
        else:
            results.append({"url": url, "data": resp.json()})
    return results

asyncio.gather(*tasks, return_exceptions=True) 是关键------return_exceptions=True 使得单个任务的失败不会取消其他任务。如果不加这个参数,第一个任务抛出异常时,所有未完成的任务都会被取消,返回的是一堆 CancelledError

asyncio.as_completed() 适合"哪个先返回就先处理哪个"的场景:

python 复制代码
async def fetch_first_available(client, urls: list[str]):
    async with client:
        tasks = [client.get(url) for url in urls]
        for coro in asyncio.as_completed(tasks):
            try:
                resp = await coro
                return resp.json()  # 拿到第一个成功的结果就返回
            except Exception:
                continue  # 继续等下一个
    raise Exception("所有源均不可用")

9. 实战:生产级 HTTP 客户端封装

综合以上所有模式,构建一个可复用的 HTTP 客户端:

python 复制代码
import httpx
import asyncio
import time
import uuid
import random
import logging
from dataclasses import dataclass
from enum import Enum

logger = logging.getLogger(__name__)

@dataclass
class ClientConfig:
    base_url: str = ""
    connect_timeout: float = 3.0
    read_timeout: float = 30.0
    max_connections: int = 50
    max_keepalive: int = 20
    max_retries: int = 3
    rate_limit: int = 0  # 0 表示不限制
    circuit_threshold: int = 5
    circuit_recovery: float = 30.0

class ApiClient:
    """生产级 HTTP 客户端:连接池 + 重试 + 熔断 + 限速 + 日志"""

    def __init__(self, config: ClientConfig):
        self.config = config
        self._client: httpx.AsyncClient | None = None
        self._breaker = CircuitBreaker(
            failure_threshold=config.circuit_threshold,
            recovery_timeout=config.circuit_recovery,
        )
        if config.rate_limit > 0:
            self._limiter = RateLimiter(max_calls=config.rate_limit)
        else:
            self._limiter = None
        self._metrics: list[dict] = []

    @property
    def client(self) -> httpx.AsyncClient:
        if self._client is None:
            self._client = httpx.AsyncClient(
                base_url=self.config.base_url,
                timeout=httpx.Timeout(
                    connect=self.config.connect_timeout,
                    read=self.config.read_timeout,
                ),
                limits=httpx.Limits(
                    max_connections=self.config.max_connections,
                    max_keepalive_connections=self.config.max_keepalive,
                ),
                event_hooks={
                    "request": [self._on_request],
                    "response": [self._on_response],
                },
            )
        return self._client

    def _on_request(self, request: httpx.Request):
        request.headers["X-Request-ID"] = str(uuid.uuid4())[:8]
        request.extensions["start_time"] = time.monotonic()

    def _on_response(self, response: httpx.Response):
        elapsed = time.monotonic() - response.request.extensions["start_time"]
        self._metrics.append({
            "url": str(response.url),
            "status": response.status_code,
            "elapsed": elapsed,
        })

    async def get(self, path: str, **kwargs):
        return await self._request("GET", path, **kwargs)

    async def post(self, path: str, **kwargs):
        return await self._request("POST", path, **kwargs)

    async def _request(self, method: str, path: str, **kwargs):
        if self._limiter:
            await self._limiter.acquire()

        async def do_request():
            return await self.client.request(method, path, **kwargs)

        for attempt in range(self.config.max_retries + 1):
            try:
                return await self._breaker.call(do_request)
            except (httpx.ReadTimeout, httpx.ConnectError) as e:
                if attempt == self.config.max_retries:
                    logger.error("%s %s 请求最终失败:%s", method, path, e)
                    raise
                delay = 2 ** attempt + random.uniform(0, 1)
                logger.warning("%s %s 重试 %d/%d,等待 %.1fs", 
                    method, path, attempt + 1, self.config.max_retries, delay)
                await asyncio.sleep(delay)
            except CircuitBreakerOpenError:
                logger.error("断路器已熔断:%s %s", method, path)
                raise

    async def close(self):
        if self._client:
            await self._client.aclose()

    def get_stats(self) -> dict:
        return {
            "total_requests": len(self._metrics),
            "avg_latency": (
                sum(m["elapsed"] for m in self._metrics) / len(self._metrics)
                if self._metrics else 0
            ),
            "breaker_state": self._breaker.state.value,
        }

这个 ApiClient 封装了本文讨论的所有工程化特性:连接池复用、三级超时控制、指数退避重试、速率限制、断路器熔断、请求追踪。使用时只需配置 ClientConfig,通过依赖注入在 FastAPI 中共享实例:

python 复制代码
github_client = ApiClient(ClientConfig(
    base_url="https://api.github.com",
    rate_limit=10,  # GitHub 未认证 API 限制 60 次/小时
))

repos = await github_client.get("/search/repositories", params={"q": "fastapi"})

关于上述代码中依赖注入和生命周期管理的底层原理,在 Python 实战第一篇文章的依赖注入章节有详细阐述,Go 的读者可以对照理解其中设计模式的共通之处。


总结

requests.get(url)ApiClient 的完整封装,核心的变化不是代码量的增加,而是对"HTTP 调用可能失败"这件事的工程化应对:
#mermaid-svg-bmJM2rx5ed5y1a6z{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bmJM2rx5ed5y1a6z .error-icon{fill:#552222;}#mermaid-svg-bmJM2rx5ed5y1a6z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bmJM2rx5ed5y1a6z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bmJM2rx5ed5y1a6z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bmJM2rx5ed5y1a6z .marker.cross{stroke:#333333;}#mermaid-svg-bmJM2rx5ed5y1a6z svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bmJM2rx5ed5y1a6z p{margin:0;}#mermaid-svg-bmJM2rx5ed5y1a6z .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z .cluster-label text{fill:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z .cluster-label span{color:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z .cluster-label span p{background-color:transparent;}#mermaid-svg-bmJM2rx5ed5y1a6z .label text,#mermaid-svg-bmJM2rx5ed5y1a6z span{fill:#333;color:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z .node rect,#mermaid-svg-bmJM2rx5ed5y1a6z .node circle,#mermaid-svg-bmJM2rx5ed5y1a6z .node ellipse,#mermaid-svg-bmJM2rx5ed5y1a6z .node polygon,#mermaid-svg-bmJM2rx5ed5y1a6z .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bmJM2rx5ed5y1a6z .rough-node .label text,#mermaid-svg-bmJM2rx5ed5y1a6z .node .label text,#mermaid-svg-bmJM2rx5ed5y1a6z .image-shape .label,#mermaid-svg-bmJM2rx5ed5y1a6z .icon-shape .label{text-anchor:middle;}#mermaid-svg-bmJM2rx5ed5y1a6z .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bmJM2rx5ed5y1a6z .rough-node .label,#mermaid-svg-bmJM2rx5ed5y1a6z .node .label,#mermaid-svg-bmJM2rx5ed5y1a6z .image-shape .label,#mermaid-svg-bmJM2rx5ed5y1a6z .icon-shape .label{text-align:center;}#mermaid-svg-bmJM2rx5ed5y1a6z .node.clickable{cursor:pointer;}#mermaid-svg-bmJM2rx5ed5y1a6z .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bmJM2rx5ed5y1a6z .arrowheadPath{fill:#333333;}#mermaid-svg-bmJM2rx5ed5y1a6z .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bmJM2rx5ed5y1a6z .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bmJM2rx5ed5y1a6z .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bmJM2rx5ed5y1a6z .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bmJM2rx5ed5y1a6z .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bmJM2rx5ed5y1a6z .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bmJM2rx5ed5y1a6z .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bmJM2rx5ed5y1a6z .cluster text{fill:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z .cluster span{color:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bmJM2rx5ed5y1a6z .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bmJM2rx5ed5y1a6z rect.text{fill:none;stroke-width:0;}#mermaid-svg-bmJM2rx5ed5y1a6z .icon-shape,#mermaid-svg-bmJM2rx5ed5y1a6z .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bmJM2rx5ed5y1a6z .icon-shape p,#mermaid-svg-bmJM2rx5ed5y1a6z .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bmJM2rx5ed5y1a6z .icon-shape .label rect,#mermaid-svg-bmJM2rx5ed5y1a6z .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bmJM2rx5ed5y1a6z .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bmJM2rx5ed5y1a6z .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bmJM2rx5ed5y1a6z :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
拒绝
可用
耗尽

瞬时故障


关闭
达到阈值
已熔断
发起 HTTP 请求
速率限制
连接池可用
等待或快速失败
发送请求
等待 pool timeout
成功?
记录指标并返回
重试次数未超?
指数退避 + 抖动
断路器
记录失败+1
熔断
快速失败

这条链路上的每一环都对应一个明确的量化参数:connect=3smax_retries=3failure_threshold=5recovery_timeout=30s。这些数值不是拍脑袋决定的,而是基于"恢复概率"和"业务容忍度"的折中------超时太短则正常请求被误杀,太长则下游故障扩散。实际使用中应根据服务 SLA 和调用链路复杂度调优,而非照搬默认值。


如果这篇文章中的 HTTP 客户端设计模式对项目实践有帮助,欢迎点赞、收藏、关注。持续输出高质量技术内容离不开读者的支持。

相关推荐
24zhgjx-fuhao1 小时前
ISIS:多区域集成IS-IS
网络·智能路由器
小熊officer1 小时前
网络渗透和网络安全
网络·安全·web安全
饿了吃洗衣凝珠1 小时前
【无标题】
运维·服务器·网络
状元岐1 小时前
1. ModBus从入门到精通
网络
爱讲故事的1 小时前
计算机网络第六讲复习博客:链路层与局域网
网络·计算机网络·智能路由器
江屿风1 小时前
C++OJ题经验总结(竞赛)4
开发语言·c++·笔记·算法·dp·双指针
Deep-w1 小时前
【MATLAB】微电网四DG逆变器下垂策略与分布式MPC协同控制仿真分析
开发语言·分布式·算法·matlab
酉鬼女又兒1 小时前
零基础入门计算机网络:定义、分类与核心性能指标
开发语言·计算机网络·考研·青少年编程·职场和发展·php
luweis1 小时前
企智孪生 ETA (3.5 执行层技术落地)【浙江联保网络 卢伟舜】
网络·人工智能·程序人生·职场和发展·学习方法