Modal SDK 客户端为什么被打开了两次:异步资源的生命周期边界
读完你能: 判断一个异步 SDK 的工厂函数返回的资源处于什么状态;写出“只打开一次、一定关闭一次”的适配器;用 fake client 覆盖成功、失败、取消几条路径。
1. 现象:加上 async with 之后反而报错
我们的服务需要用 Modal 的 Python SDK 读取账单和应用列表。每次调用都要用存储的凭据临时建一个客户端,用完关闭。最初的写法看起来很规范:
# 简化伪代码:最初的写法
async with await Client.from_credentials(*credentials) as client:
... # 业务调用
在本地 mock 测试里这段代码没有问题,但一接上真实 SDK,第一次调用就失败了:异常不是认证失败,也不是网络错误,而是 SDK 内部“打开”步骤里的一个断言。用户看到的只是“同步失败”。
2. 根因:工厂返回的已经是打开的客户端
把这段代码拆开看,其实有两个动作:
await Client.from_credentials(...):创建客户端,并且已经完成打开(建立连接、校验凭据);async with ... as client:进入异步上下文时,__aenter__会再执行一次打开逻辑。
客户端内部有“只能打开一次”的状态检查,第二步撞上了它。用状态转换表示就是:
| 步骤 | 期望状态 | 实际状态 |
|---|---|---|
| 工厂返回 | 未打开 | 已打开 |
进入 async with |
未打开 → 打开 | 已打开 → 再次打开,断言失败 |
退出 async with |
打开 → 关闭 | 未执行到 |
所以问题不在于 async with 本身,而在于同一个资源被两个地方各打开了一次。很多 SDK 同时提供两种用法:要么由上下文管理器负责打开和关闭,要么由工厂返回一个已经可用的对象、由调用方负责关闭。两种用法不能叠加。
Modal 的官方文档也说明了 from_credentials 返回的是已连接的客户端。需要说明的是:我们对“重复打开会触发断言”的判断,依据的是 1.6.1 版本下的实际报错和修复记录,并没有逐个版本核对 SDK 源码。所以这里不推论所有版本都会有同样的断言。
3. 解法与取舍:由适配器负责关闭
修复的思路是只保留一种生命周期:工厂负责打开,适配器负责关闭。一次调用的资源归属如下:
- 创建失败:工厂抛出异常,此时手里没有客户端,也就不需要关闭;
- 业务失败、被取消:客户端已经打开,必须在
finally里关闭; - 关闭:每次调用恰好关闭一次。
还有一个需要讲清楚的取舍:当时版本只提供了一个下划线开头的私有关闭方法,项目里直接调用了它。私有接口会随版本变化,所以我们在依赖里固定了 SDK 版本,并把调用集中在一个适配器里;升级 SDK 时只需检查这一处。它不是可以长期依赖的公共 API。
4. 关键代码:最小适配器
下面是去掉凭据和业务细节之后的概念代码(简化,非项目原文)。客户端工厂通过构造参数注入,测试时可以换成 fake:
class Adapter:
def __init__(self, open_client):
self._open_client = open_client # 工厂:返回时客户端已经打开
async def run(self, fn):
client = await self._open_client() # 失败时没有可关闭的对象
try:
return await fn(client)
finally:
await client.close() # 只关一次,异常与取消也走这里
项目里的实际实现还多做了一件事:关闭时如果出错,就把这个错误吞掉,以免它覆盖掉更重要的业务异常。这在“关闭失败只影响一个临时连接”的场景下可以接受,但不适合当作通用模板。如果关闭失败意味着资源泄漏,至少应该记录下来,而不是悄悄忽略。上面的示例代码选择让关闭错误照常抛出。
5. 验证:分别数打开和关闭的次数
光看业务结果是否正确,不足以说明生命周期没问题,还要分别检查打开次数、关闭次数和各条异常路径。下面的测试只用 fake client,不需要任何真实凭据:
class FakeClient:
def __init__(self, fail_close=False):
self.opens, self.closes, self.fail_close = 1, 0, fail_close # 工厂交出时已打开一次
async def open(self): # 模拟“进入上下文时再打开一次”
assert self.opens == 0, 'already open'
self.opens += 1
async def close(self):
self.closes += 1
if self.fail_close:
raise RuntimeError('close failed')
| 场景 | 断言 |
|---|---|
| 正常调用 | 返回值正确;打开 1 次、关闭 1 次 |
对已打开的客户端再调用 open() |
触发断言(复现原问题的状态) |
| 工厂抛出异常 | 异常原样传出,业务函数没有被调用 |
| 业务函数抛出异常 | 异常原样传出,关闭 1 次 |
| 任务被取消 | 抛出 CancelledError,关闭 1 次 |
| 关闭本身失败 | 关闭错误能被调用方看到 |
这 6 条用 unittest.IsolatedAsyncioTestCase 写成测试后,本地全部通过。需要区分的是:它们证明的是适配器自身的状态逻辑,并不能证明真实 SDK 的行为。真实 SDK 是否修好,要看修复后在真实环境里做的只读调用。我们的修复上线后,这类调用已能正常完成。
一句话
接入一个异步 SDK 时,先查清楚工厂返回的对象处于什么状态,再决定由谁来关闭它。
- 感谢你赐予我前进的力量

