2.3 安全接入只能同步调用的SDK
安全接入只能同步调用的SDK
外部服务的 SDK 经常只有同步方法,但业务路由已经是异步的。把同步方法直接写进 async def 会复现上一节的事件循环阻塞;把它随意改成协程也不会让底层网络请求获得异步能力。更稳妥的边界是:保留 SDK 的同步语义,在调用处使用 run_in_threadpool,再把同步库的超时和异常转换为本 API 能理解的响应。
线程池只解决执行位置
run_in_threadpool 会把一次同步调用提交到 Starlette 使用的线程执行机制,等待期间事件循环可以处理其他协程。它不会把同步客户端变成异步客户端,也不会自动增加外部服务的容量。调用仍会占用一个线程,并等待客户端自己的连接、读取或其他阶段超时结束。
当前 AnyIO 的默认线程限制通常是 40 个 token,这是 Starlette 同步路由、同步依赖和其他使用该机制的工作共享的默认限制,不是本 SDK 专属的线程池。不要看到排队就盲目调大;先确认版本、观测线程等待和上游连接限制,再决定是否需要独立的执行资源或任务系统。
线程隔离也不等于客户端可以随意跨线程共享。真实 SDK 是否线程安全、连接池是否允许跨线程、关闭方法由谁调用,都要查该 SDK 的文档。如果没有明确保证,可在每次工作线程内创建并关闭客户端;若 SDK 明确支持共享,才把生命周期交给应用启动和关闭流程。本节替身没有可变连接状态,便于演示调用边界,不能替代真实客户端的线程安全核查。
把外部故障映射成稳定响应
示例中的替身模拟三种结果:正常返回、外部超时、外部一般错误。真正的同步 SDK 可能分别抛出自己的 ConnectTimeout、ReadTimeout、认证错误或响应状态异常。路由层只捕获已经确认的异常类型,并将它们转换为 504 或 502;不要用宽泛的 except Exception 把编程错误、配置错误和数据损坏都伪装成“上游失败”。内部日志可以记录异常上下文,但面向客户端的消息不要泄露密钥、内部地址或完整响应。
完整的同步 SDK 替身
下面的程序不访问网络。SyncCatalogClient.fetch() 用短暂的 time.sleep()模拟同步 SDK 的 I/O,并对特定 ID 返回可控故障。自检通过 HTTP 客户端检查成功、超时和上游错误三条路径,便于之后替换为真实 SDK 时保留响应契约。
<!-- file: ch02_sdk/sync_sdk_demo.py -->
from __future__ import annotations
import time
from fastapi import FastAPI, HTTPException
from fastapi.concurrency import run_in_threadpool
from fastapi.testclient import TestClient
class SyncSDKTimeout(TimeoutError):
"""同步 SDK 报告连接或读取超时。"""
class SyncSDKError(RuntimeError):
"""同步 SDK 报告其他可预期的上游故障。"""
class SyncCatalogClient:
def fetch(self, item_id: str) -> dict[str, str]:
# 本地替身用 ID 触发故障,不模拟真实计时超时。
if item_id == "timeout":
raise SyncSDKTimeout("catalog read timed out")
if item_id == "error":
raise SyncSDKError("catalog returned an invalid response")
# 用本地等待模拟一个同步客户端的短暂 I/O。
time.sleep(0.02)
return {"id": item_id, "title": f"Item {item_id}"}
def fetch_with_sync_sdk(item_id: str) -> dict[str, str]:
# 真实项目中,这里应按 SDK 文档决定客户端的创建和关闭方式。
client = SyncCatalogClient()
return client.fetch(item_id)
app = FastAPI(title="同步 SDK 线程隔离示例")
@app.get("/catalog/{item_id}")
async def get_catalog_item(item_id: str) -> dict[str, str]:
try:
item = await run_in_threadpool(fetch_with_sync_sdk, item_id)
except SyncSDKTimeout as exc:
raise HTTPException(
status_code=504,
detail="外部目录服务响应超时",
) from exc
except SyncSDKError as exc:
raise HTTPException(
status_code=502,
detail="外部目录服务暂时不可用",
) from exc
return item
def self_check() -> None:
with TestClient(app) as client:
success = client.get("/catalog/a-1")
timeout = client.get("/catalog/timeout")
upstream_error = client.get("/catalog/error")
assert success.status_code == 200
assert success.json() == {"id": "a-1", "title": "Item a-1"}
assert timeout.status_code == 504
assert timeout.json()["detail"] == "外部目录服务响应超时"
assert upstream_error.status_code == 502
assert upstream_error.json()["detail"] == "外部目录服务暂时不可用"
print("sync_sdk_demo self-check passed")
if __name__ == "__main__":
self_check()
运行检查:
python ch02_sdk/sync_sdk_demo.py
替换成真实客户端时的检查清单
首先查清楚客户端支持哪些超时阶段。HTTP 客户端常把超时分为连接(connect)、读取(read)、写入(write)和连接池等待(pool);有些库还提供一次业务调用的 deadline。以 HTTPX 为例,httpx.Timeout(5.0, connect=0.5) 为其他阶段设置默认值并单独缩短连接超时,它不应被误解为涵盖重试、分页或多次调用的总业务 deadline。不要只依赖操作系统默认超时,否则线程可能长时间占用;具体参数名和异常类型以正在使用的 SDK 版本为准。
然后决定客户端的生命周期。如果客户端持有连接池,应用级复用可能减少建连成本,但必须确认它可以被多个工作线程使用,并在应用关闭时释放;不安全的客户端应在线程函数内部创建。还要让连接池容量、线程限制和上游并发限制彼此匹配,避免把等待从事件循环挪到一条更长的线程队列里。
最后处理取消语义。在 AnyIO 默认的 abandon_on_cancel=False 行为下,等待线程结果的调用方取消作用域时通常会继续等线程完成;即便显式选择 abandon_on_cancel=True,也只是放弃等待,已经进入工作线程的函数仍不会被强制杀死。不能据此断言底层同步调用已经停止。若 SDK 支持自己的取消、总超时或关闭机制,应按其文档接入。不要为了“能取消”而强制杀线程,也不要在异常处理里吞掉未预期的异常。
run_in_threadpool 适合少量、边界清晰的同步 I/O。若调用耗时很长、吞吐需求高,或任务不必占用请求生命周期,应把工作转为有持久语义的任务系统;这已经超出本节的最小示例。CPU 密集型同步函数也不应借此获得“异步并行”的错觉,2.4 节会单独区分它。
来源与相邻小节
- 主要参考:fastapi-best-practices 中文 README 的“如果必须使用同步 SDK,请在线程池中运行它”主题。本节保留
run_in_threadpool思路,改写为可自检的本地替身。 - 官方说明:Starlette 线程池、AnyIO 在线程中运行同步代码、HTTPX 超时。
- 相邻小节:2.1 选择同步路由还是异步路由、2.2 复现并定位事件循环阻塞、2.4 区分 CPU 任务与后台任务。
阅读相邻小节时,请在教程目录中选择对应标题。