文章目录
-
- [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 的超时异常类型细分为 ConnectTimeout、ReadTimeout、WriteTimeout 和 PoolTimeout------这意味着异常处理代码可以精确地区分不同的超时场景:
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 | 内部错误 | ⚠️ 谨慎重试(可能是业务逻辑错误) |
基于 httpx 和 tenacity 的组合可以构建生产级的重试策略:
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. 中间件模式:请求/响应拦截器
httpx 的 event_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=3s、max_retries=3、failure_threshold=5、recovery_timeout=30s。这些数值不是拍脑袋决定的,而是基于"恢复概率"和"业务容忍度"的折中------超时太短则正常请求被误杀,太长则下游故障扩散。实际使用中应根据服务 SLA 和调用链路复杂度调优,而非照搬默认值。
如果这篇文章中的 HTTP 客户端设计模式对项目实践有帮助,欢迎点赞、收藏、关注。持续输出高质量技术内容离不开读者的支持。