codecamp

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 可能分别抛出自己的 ConnectTimeoutReadTimeout、认证错误或响应状态异常。路由层只捕获已经确认的异常类型,并将它们转换为 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 节会单独区分它。

来源与相邻小节

阅读相邻小节时,请在教程目录中选择对应标题。

2.2 复现并定位事件循环阻塞
2.4 区分CPU任务与后台任务
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }