下载视频、抓缩略图、调 DeepSeek、对接 Stripe、转写音频,每个环节都离不开 HTTP 调用。一开始有人用 requests,同步调用把事件循环卡死;有人用 aiohttp,写法又绕,会话管理、超时配置都比预想繁琐。我最后统一用 httpx 做异步 HTTP 客户端,一套 API 同时支持同步和异步,迁移成本还低。下面是我在这个项目里积累的完整实践,从选型到踩坑再到性能优化。
一、为什么选 httpx
选型的时候我把主流方案摆在一起比过一轮,结论很干脆。
| 库 | 同步/异步 | 类型提示 | 性能 | 维护 |
|---|---|---|---|---|
| requests | 同步 | 弱 | 中 | 活跃 |
| aiohttp | 异步 | 弱 | 高 | 活跃 |
| httpx | 同步+异步 | ✅ | 高 | 活跃 |
平台选 httpx 因为:
- 同步+异步统一 API;
- 类型提示好;
- HTTP/2 支持;
- 与 requests 兼容(迁移成本低)。
requests 的同步阻塞不适合异步项目,一旦混进 async 函数,整个事件循环都要等它;aiohttp 功能确实全,但学习成本摆在那里。httpx 的同步接口和 requests 几乎一致,团队迁移只需要把 requests.get 换成 httpx.get,异步场景再换 AsyncClient,这是它胜出的关键。
二、基础用法
异步客户端的三个基础场景:GET、POST、流式读取。日常开发里八成以上的调用就这三种,先把它们写熟,其他高级特性都是在这上面叠加的。
import httpx
# 异步 GET
async with httpx.AsyncClient() as client:
response = await client.get('https://api.example.com/data')
data = response.json()
# 异步 POST
async with httpx.AsyncClient() as client:
response = await client.post(
'https://api.example.com/submit',
json={'key': 'value'}
)
# 流式
async with httpx.AsyncClient() as client:
async with client.stream('GET', url) as response:
async for chunk in response.aiter_bytes():
process(chunk)
注意这里用 async with 管理客户端生命周期,退出时自动关闭连接,不会泄漏文件描述符。流式场景用 aiter_bytes 按块读取,响应再大也不会整块塞进内存,这是后面大文件下载能顺利落地的根基。
三、全局 Client 配置
每个请求都 new 一个 client 是浪费,连接没法复用,TLS 握手反复进行,高并发下文件描述符也顶不住。我把它放进 FastAPI 的 lifespan,应用启动时创建,退出时统一关闭。
# main.py
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动
app.state.http = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True,
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20
),
headers={'User-Agent': 'AI-Video-Summarizer/1.0'}
)
yield
# 关闭
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
依赖注入:
async def get_http(request: Request) -> httpx.AsyncClient:
return request.app.state.http
@router.get('/api/proxy')
async def proxy(http: httpx.AsyncClient = Depends(get_http)):
response = await http.get('https://api.example.com')
return response.json()
全局 client 带统一超时、统一 User-Agent、连接池上限,所有接口共享。通过 Depends 注入后,每个接口拿到的是同一个实例,连接被充分复用,握手次数大幅下降。lifespan 里的 aclose 保证进程退出前连接优雅关闭,不会留下半开连接。
四、超时控制
不同场景对超时的容忍完全不同:普通 API 秒级就该超时,下载视频要几分钟。如果全用同一个超时值,要么普通接口被慢请求拖死,要么大文件下载中途断掉,所以我把超时按场景拆成几档。
class TimeoutConfig:
FAST = 5.0 # 普通 API
NORMAL = 30.0 # 视频信息
SLOW = 120.0 # 下载文件
STREAM = None # 流式不限时
# 用
client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=5.0,
read=30.0,
write=10.0,
pool=5.0
)
)
# 单次超时
response = await client.get(url, timeout=10.0)
connect、read、write、pool 分别设置,比单一超时精准:连接 5 秒建不起来就该放弃,读 30 秒没数据说明对端有问题。个别接口还能用单次 timeout 覆盖默认值,比如健康检查用 5 秒快速失败,慢的节点直接被摘掉。
五、限流
下载接口对外部服务有配额,并发一高就被对方限流甚至封 IP,全链路都会跟着遭殃。我在应用层用信号量控制并发,先把自家请求控制在配额内。
import asyncio
semaphore = asyncio.Semaphore(10) # 最多 10 并发
async def fetch_with_limit(client, url):
async with semaphore:
return await client.get(url)
信号量把并发钉死在 10,再多请求也在门口排队。这个值要按对端配额和单请求耗时来调:太小浪费带宽,吞吐上不去;太大会触发对端限流,得不偿失。上线后看实际耗时不限流,再微调数字。
六、文件下载
视频文件从几十 MB 到几 GB,下载方式必须分场景,不能一把梭。
1. 完整下载到内存
async def download_file(client, url: str) -> bytes:
response = await client.get(url)
response.raise_for_status()
return response.content
2. 流式下载到磁盘
async def download_to_file(client, url: str, path: str):
async with client.stream('GET', url, follow_redirects=True) as response:
response.raise_for_status()
with open(path, 'wb') as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
if chunk:
f.write(chunk)
平台用这个下载视频文件(几十 MB 到几 GB)。
3. 进度回调
async def download_with_progress(client, url: str, path: str,
on_progress=None):
async with client.stream('GET', url) as response:
total = int(response.headers.get('content-length', 0))
downloaded = 0
with open(path, 'wb') as f:
async for chunk in response.aiter_bytes(8192):
f.write(chunk)
downloaded += len(chunk)
if on_progress and total:
on_progress(downloaded / total)
小文件直接 content 一把梭,几 MB 无压力;大文件必须走流式,内存占用稳定在几十 MB。进度回调基于 content-length 计算百分比,前端能实时看到下载进度条;对端不返回 content-length 时 total 为 0,回调自动跳过,不会出现除零错误。
七、SSE 流式响应
DeepSeek 的总结是流式返回的,用户要看到逐字生成的效果,必须解析 SSE 事件流,而不是傻等整个响应。
async def stream_deepseek(prompt: str):
async with httpx.AsyncClient() as client:
async with client.stream(
'POST',
'https://api.deepseek.com/v1/chat/completions',
headers={'Authorization': f'Bearer {API_KEY}'},
json={
'model': 'deepseek-chat',
'messages': [{'role': 'user', 'content': prompt}],
'stream': True
}
) as response:
async for line in response.aiter_lines():
if line.startswith('data: '):
data = line[6:]
if data == '[DONE]':
break
chunk = json.loads(data)
delta = chunk['choices'][0]['delta'].get('content', '')
if delta:
yield delta
这里用 aiter_lines 按行读,识别 data: 前缀,逐个 yield 增量内容,SSE 的跨行分块由 httpx 内部处理好。一个容易忽略的点:如果你自己攒 buffer 做行拼接,要小心单行被 TCP 分包切碎的情况,按行读则没这个烦恼。
八、并发请求
批量总结多个视频时,一个个串行请求太慢,用户等不起。我用 asyncio.gather 并发跑,把多个请求的耗时压到单次请求的水平。
async def fetch_multiple(urls: list[str]) -> list[dict]:
async with httpx.AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks, return_exceptions=True)
return [
r.json() if not isinstance(r, Exception) else {'error': str(r)}
for r in responses
]
return_exceptions=True 很关键,某个请求失败不会拖垮整个 gather,错误被收进结果列表单独标记。这里要配合信号量使用,否则并发数不可控,容易重演”没限制并发”那个坑。
九、重试机制
httpx 不内置重试,遇到网络抖动或 5xx,请求就直接失败。我用 tenacity 补上指数退避重试,让瞬时故障自己恢复。
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10)
)
async def call_with_retry(client, url):
response = await client.get(url)
response.raise_for_status()
return response.json()
针对特定状态码重试:
from httpx import HTTPStatusError
@retry(
stop=stop_after_attempt(3),
retry=retry_if_exception_type(HTTPStatusError),
wait=wait_exponential(multiplier=1, min=2, max=10)
)
async def call_api(url):
async with httpx.AsyncClient() as client:
response = await client.get(url)
if response.status_code in (429, 500, 502, 503, 504):
response.raise_for_status() # 触发重试
return response.json()
重试只针对可恢复的错误:429 限流、5xx 服务端故障、网络异常。4xx 业务错误(参数不对、没权限)重试也没用,别浪费请求次数。指数退避避免重试风暴,多实例同时失败时不会在同一秒全部打向对端。
十、HTTP/2 支持
和同一域名的大量并发请求,HTTP/2 的多路复用能把性能拉起来,一个连接顶一堆连接。
client = httpx.AsyncClient(http2=True)
需要安装 h2:
pip install httpx[http2]
HTTP/2 优势:
- 多路复用(一个连接并发多个请求);
- Header 压缩;
- 服务器推送。
开启后同一个 TCP 连接上可以并发多个请求,省掉大量握手开销。但部分老后端只支持 HTTP/1.1,开启后连接会失败,需要先确认目标服务支持,或者做降级判断,别一开就全部请求挂掉。
十一、Cookie 管理
有些场景需要保持会话,比如带登录态的请求,Cookie 管理就要接进来。
client = httpx.AsyncClient(
cookies={'session': 'abc123'}
)
# 也可以在请求时设
response = await client.get(url, cookies={'extra': 'value'})
Cookie Jar:
jar = httpx.Cookies()
jar.set('session', 'abc123', domain='example.com')
client = httpx.AsyncClient(cookies=jar)
客户端创建时传 cookies,请求期间 Cookie Jar 会自动管理 Set-Cookie,响应里设置的新 Cookie 也会被记住。注意 SameSite 策略,跨站请求时 Cookie 不会自动带上,这个坑我们踩过,后来靠显式指定域和 path 解决。
十二、代理
项目里有部分接口必须走代理才能访问,代理配置也走 client 层统一处理。
# HTTP 代理
client = httpx.AsyncClient(
proxy='http://proxy.example.com:8080'
)
# 多个代理随机
import random
PROXIES = ['http://p1:8080', 'http://p2:8080']
client = httpx.AsyncClient(proxy=random.choice(PROXIES))
单代理配置简单,适合固定出口的场景;多代理随机分摊流量,适合代理节点经常失效的情况。多代理要盯着节点健康,失效的代理会让请求连环超时,宁可少几个节点也要保证存活。
十三、SSL 配置
内部服务用了自签名证书,默认校验会直接失败,需要指定 CA 文件。
# 自定义 CA
client = httpx.AsyncClient(
verify='/path/to/ca-bundle.crt'
)
# 跳过验证(不推荐)
client = httpx.AsyncClient(verify=False)
verify 指向自定义 CA 文件,内网服务就能正常访问。跳过验证只适合本地调试,生产环境禁用,否则中间人攻击毫无防护,这是安全红线,团队规范里写得很清楚。
十四、文件上传
把视频上传到存储服务,用 multipart 表单,文件和相关字段一起提交。
async def upload_file(client, url: str, file_path: str):
with open(file_path, 'rb') as f:
files = {'file': (file_path, f, 'application/octet-stream')}
response = await client.post(url, files=files)
return response.json()
multipart/form-data:
files = {
'file': (
'video.mp4', # filename
open('video.mp4', 'rb'),
'video/mp4' # content-type
)
}
data = {'description': 'my video'}
response = await client.post(url, files=files, data=data)
files 参数会自动编码成 multipart/form-data,data 里的字段和文件一起提交。文件对象要在 with 块里打开,保证读取期间文件句柄不关闭;如果没带 content-type,服务端可能猜错文件类型,所以显式写全。
十五、Webhook 验证
Stripe 的支付回调必须验签,否则伪造请求就能篡改订单状态。验签放接口入口,第一时间拦截。
async def verify_stripe_webhook(client, payload: bytes,
signature: str) -> dict:
async with client as c:
event = stripe.Webhook.construct_event(
payload, signature, WEBHOOK_SECRET
)
return event
验签用 Stripe 官方 SDK,本地校验签名,验不过直接抛异常。Webhook 接口要尽快返回 200,处理逻辑丢到后台任务,避免对端超时重推造成重复处理,幂等校验也要在业务侧补上。
十六、错误处理
外部调用必须分类处理异常,不能一把梭 try-except 把错误全吞掉,否则线上问题全靠猜。
from httpx import (
HTTPStatusError, RequestError,
TimeoutException, ConnectError
)
try:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
except TimeoutException:
log.error('请求超时')
except ConnectError:
log.error('连接失败')
except HTTPStatusError as e:
log.error(f'HTTP 错误: {e.response.status_code}')
if e.response.status_code == 429:
retry_after = e.response.headers.get('Retry-After')
log.warning(f'限流,{retry_after}秒后重试')
except RequestError as e:
log.error(f'请求错误: {e}')
先捕具体的,再捕兜底的 RequestError,顺序别写反。429 时读取 Retry-After 头按服务端要求等待,这是对限流服务的基本尊重。错误日志里带上状态码和耗时,排查问题时不用翻原始请求记录。
十七、连接池
连接池配置直接决定并发能力和资源占用,是全局 client 里影响面较大的参数。
client = httpx.AsyncClient(
limits=httpx.Limits(
max_connections=100, # 总连接数
max_keepalive_connections=20, # keep-alive 数
keepalive_expiry=30 # keep-alive 时间
)
)
复用连接减少握手开销。
maxconnections 是总连接数上限,maxkeepaliveconnections 是保留的空闲连接数,keepaliveexpiry 控制空闲连接存活时间。这几个参数按业务并发量调:太小会排队等待,吞吐上不去;太大会占满文件描述符,进程反而报错。上线后用监控数据反推,别拍脑袋。
十八、踩过的坑
真实项目里踩过的坑按严重程度列出来,每一个都对应过一次线上问题。
- 没限制并发:1000 个并发请求把服务端打挂。Semaphore 限流。
- 同步调用阻塞事件循环:requests 库会阻塞。必须用 httpx async。
- 未关 Client:每次请求都新建 client 浪费资源。复用全局 client。
- 超时未设:httpx 默认 5s 不够。设 30s。
- 文件下载 OOM:1GB 视频加载到内存爆。流式下载。
- SSE 解析:单行可能跨 chunk 边界。攒 buffer。
- HTTP/2 兼容性:部分老后端不支持,连接失败。
- Cookie SameSite:跨站请求 Cookie 不带。
- DNS 解析慢:连接前 DNS 查询可能 1s+。预解析或 usednscache。
- 异步上下文管理:async with 退出时未关闭。手动
aclose()。
这里最伤的是没限制并发和同步调用阻塞:前者把对端打挂,后者把自己卡死,都直接引发线上事故。OOM 问题在下载 1GB 视频时真实发生过,改成流式后内存稳定在几十 MB。DNS 解析慢属于隐藏问题,高并发时会被放大,值得提前处理。
十九、性能优化
性能优化的几个抓手按性价比排序,先做收益明显的。
# 1. 复用连接
client = httpx.AsyncClient() # 全局一个
# 2. HTTP/2 多路复用
client = httpx.AsyncClient(http2=True)
# 3. DNS 缓存
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=2)
)
# 4. 限制超时
client = httpx.AsyncClient(timeout=10.0)
复用连接收益明显且几乎零成本,先做;HTTP/2 对同域并发帮助大,次之;超时限制避免慢请求拖死连接池,属于防御性优化。DNS 缓存和 transport 重试放在最后,按实际瓶颈决定要不要上。
二十、与第三方库对比
同样一个请求,三个库的写法差异很大,放在一起看最直观。
# requests (同步)
import requests
response = requests.get(url) # 阻塞
# aiohttp (异步,但 API 复杂)
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
# httpx (统一 API)
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(url) # 与 requests 几乎一样
data = response.json()
requests 一行搞定但同步阻塞;aiohttp 异步但会话管理、超时配置都更繁琐;httpx 的写法最接近 requests,心智负担低。这也是我把它定为全项目统一 HTTP 工具的原因,新人接手代码不需要重新学一套 API。
二十一、最佳实践清单
收敛成八条写进项目开发规范,作为 HTTP 调用相关代码的审查标准。
- 全局 client 复用;
- 不同场景不同超时;
- 流式下载大文件;
- 重试用 tenacity;
- 并发用 Semaphore;
- 错误分类处理;
- HTTP/2 多路复用;
- 资源自动释放(lifespan)。
到这里,httpx 从选型到性能优化的实践链路就完整了。照这套走,外部接口调用基本不会再有资源泄漏、阻塞和并发失控的问题,后续接入新的第三方服务也只需要照着模板加一层封装。
常见问题(FAQ)
Q1:httpx 替代 requests?
替代。同步 API 兼容,迁移成本低。
Q2:httpx 怎么用 HTTP/2?
httpx[http2] 装依赖,AsyncClient(http2=True) 开启。
Q3:流式响应怎么解析 SSE?
aiter_lines() 按行读,识别 data: 前缀。