AI辅助整理。Python 3.11.9、HTTPX 0.28.1;全部请求经过本地MockTransport,样本虚构,没有网络模型调用。
异步请求成功时资源能关闭,并不代表读取失败、HTTP报错或任务取消时也一样。本篇写一个可以离线复现的实验:三个任务借用同一客户端,每个任务管理自己的响应流,任务组收束后再关闭客户端。
成果是两份完整Python文件、实际输出与8个测试。它检查对象生命周期,不测真实TCP连接复用、吞吐或远端取消效果。
1. 环境与输入输出
Python 3.11支持本例使用的TaskGroup;新环境安装httpx==0.28.1。把下列两份文件放在同一目录,执行python lifecycle_lab.py和python -m unittest -v test_lifecycle_lab。
输入是固定样本ID a、b、c。每个模拟响应分两段给出JSON,流中间主动让出一次调度,用来覆盖协程交错路径。没有API Key,没有真实接口配置,lab.invalid只作为构造Request的虚构地址。
| 观察项 | 能说明什么 | 不能说明什么 |
|---|---|---|
| 每条LabStream的close_calls | 模拟流是否收到关闭调用 | 真实socket是否释放 |
| client.is_closed | 客户端对象是否关闭 | 上游是否终止或计费 |
| transport.open_at_close | 关闭传输时是否仍有本例未关闭流 | 整个进程无泄漏 |
| 固定结果列表 | 本地样本读取与解析成功 | 模型回答正确或性能更高 |
2. 文件一:lifecycle_lab.py
LabTransport继承MockTransport,其handler只返回预设响应,不设置真实HTTPTransport回退。LabStream提供异步字节迭代和关闭计数;这两个类是本课观测工具,不是要接入生产的替代传输层。
"""Deterministic offline lifecycle lab. Every request uses MockTransport."""
import asyncio
import json
import httpx
class LabStream(httpx.AsyncByteStream):
def __init__(self, *, fail=False, gate=None, started=None):
self.fail, self.gate, self.started = fail, gate, started
self.close_calls = 0
async def __aiter__(self):
if self.started is not None:
self.started.set()
yield b'{"ok":'
if self.gate is not None:
await self.gate.wait()
else:
await asyncio.sleep(0)
if self.fail:
raise ValueError("synthetic_body_failure")
yield b'true}'
async def aclose(self):
self.close_calls += 1
class LabTransport(httpx.MockTransport):
def __init__(self, *, fail_path=None, status=200, gate=None, started=None):
self.paths, self.streams = [], []
self.close_calls = 0
self.open_at_close = None
self.fail_path, self.status = fail_path, status
self.gate, self.started = gate, started
super().__init__(self.handle)
async def handle(self, request):
self.paths.append(request.url.path)
stream = LabStream(fail=request.url.path == self.fail_path,
gate=self.gate, started=self.started)
self.streams.append(stream)
return httpx.Response(self.status, stream=stream)
async def aclose(self):
self.close_calls += 1
self.open_at_close = sum(s.close_calls == 0 for s in self.streams)
await super().aclose()
async def read_item(client, item_id):
# Borrowed client: this helper closes only its own response scope.
async with client.stream("GET", f"/items/{item_id}") as response:
response.raise_for_status()
return json.loads(await response.aread())
async def read_batch(client, item_ids):
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(read_item(client, item)) for item in item_ids]
return [task.result() for task in tasks]
async def main():
transport = LabTransport()
async with httpx.AsyncClient(transport=transport, base_url="https://lab.invalid",
trust_env=False) as client:
rows = await read_batch(client, ("a", "b", "c"))
print("results:", rows)
print("inside:", client.is_closed, [s.close_calls for s in transport.streams])
print("outside:", client.is_closed, transport.close_calls, transport.open_at_close)
if __name__ == "__main__":
asyncio.run(main())
read_item借用client,内部只创建响应作用域。即使raise_for_status或aread抛错,仍经过上下文退出路径。read_batch用TaskGroup保留任务句柄,组退出后才读取结果;结果顺序跟输入列表一致,不按哪个任务先完成来重排。
外层main拥有客户端,客户端作用域包住任务组。不要把这个层次倒过来,也不要让辅助函数把借来的客户端关闭。
3. 实际执行输出
results: [{'ok': True}, {'ok': True}, {'ok': True}]
inside: False [1, 1, 1]
outside: True 1 0
inside中的False表示客户端仍打开,三个1表示本例三条流各收到一次关闭。outside的True、1、0分别是客户端已关闭、模拟传输关闭一次、关闭传输时未发现本例未关闭的流。
这不是连接池统计;MockTransport没有模拟真实DNS、TLS、socket或上游处理。本例不会据此给出速度提升比例,也不证明单客户端就等于单连接或单并发。
4. 文件二:test_lifecycle_lab.py
import asyncio
import unittest
import httpx
from lifecycle_lab import LabTransport, read_item, read_batch
def make_client(transport):
return httpx.AsyncClient(transport=transport, base_url="https://lab.invalid", trust_env=False)
class LifecycleTests(unittest.IsolatedAsyncioTestCase):
async def test_helper_borrows_client(self):
transport = LabTransport()
async with make_client(transport) as client:
self.assertEqual(await read_item(client, "a"), {"ok": True})
self.assertFalse(client.is_closed)
self.assertEqual(transport.close_calls, 0)
self.assertEqual(transport.streams[0].close_calls, 1)
async def test_batch_reuses_one_owner_and_closes_in_order(self):
transport = LabTransport()
async with make_client(transport) as client:
self.assertEqual(await read_batch(client, ["a", "b", "c"]), [{"ok": True}] * 3)
self.assertEqual(transport.paths, ["/items/a", "/items/b", "/items/c"])
self.assertEqual([s.close_calls for s in transport.streams], [1, 1, 1])
self.assertEqual(transport.close_calls, 0)
self.assertTrue(client.is_closed)
self.assertEqual((transport.close_calls, transport.open_at_close), (1, 0))
async def test_body_error_closes_response(self):
transport = LabTransport(fail_path="/items/bad")
async with make_client(transport) as client:
with self.assertRaisesRegex(ValueError, "synthetic_body_failure"):
await read_item(client, "bad")
self.assertEqual(transport.streams[0].close_calls, 1)
self.assertFalse(client.is_closed)
self.assertEqual(transport.open_at_close, 0)
async def test_taskgroup_failure_settles_before_owner_exit(self):
transport = LabTransport(fail_path="/items/bad")
with self.assertRaises(ExceptionGroup) as caught:
async with make_client(transport) as client:
await read_batch(client, ["a", "bad", "c"])
self.assertTrue(any(isinstance(e, ValueError) for e in caught.exception.exceptions))
self.assertTrue(client.is_closed)
self.assertTrue(transport.streams)
self.assertTrue(all(s.close_calls == 1 for s in transport.streams))
self.assertEqual(transport.open_at_close, 0)
async def test_cancel_awaited_worker_closes_response(self):
gate, started = asyncio.Event(), asyncio.Event()
transport = LabTransport(gate=gate, started=started)
async with make_client(transport) as client:
task = asyncio.create_task(read_item(client, "a"))
await asyncio.wait_for(started.wait(), timeout=2)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.assertEqual(transport.streams[0].close_calls, 1)
self.assertFalse(client.is_closed)
self.assertEqual(transport.open_at_close, 0)
async def test_http_error_still_closes_response(self):
transport = LabTransport(status=503)
async with make_client(transport) as client:
with self.assertRaises(httpx.HTTPStatusError):
await read_item(client, "a")
self.assertEqual(transport.streams[0].close_calls, 1)
self.assertEqual(transport.open_at_close, 0)
async def test_closed_client_does_not_enter_handler(self):
transport = LabTransport()
async with make_client(transport) as client:
pass
with self.assertRaises(RuntimeError):
await read_item(client, "late")
self.assertEqual(transport.paths, [])
async def test_empty_batch_preserves_owner(self):
transport = LabTransport()
async with make_client(transport) as client:
self.assertEqual(await read_batch(client, []), [])
self.assertFalse(client.is_closed)
self.assertEqual(transport.open_at_close, 0)
if __name__ == "__main__":
unittest.main()
8个测试方法在本地通过。取消测试用Event确认任务已经进入响应流,再取消并await该任务,避免把“任务还没启动”误当成资源清理成功。2秒只是测试等待上限,不是接口超时推荐值或业务时延承诺。
TaskGroup异常测试检查普通异常以ExceptionGroup抛出,并确认已建立的模拟流都关闭;没有断言真实服务端被取消。HTTP错误测试使用预设503,不访问真实服务。关闭后误用测试确认没有进入模拟handler。
5. 为什么这些边界值得单独测
一是成功读取与出错读取不能共用“反正最后程序退出”的假设。二是响应关闭与客户端关闭不能混为一个动作。三是发出cancel只是请求任务取消,等待任务完成以后才能检查本地清理结果;不等待就关闭客户端,很难判断是任务自己清理还是所有者提前拆掉了资源。
本例只覆盖一次取消和正常上下文退出。强制杀进程、收集器故障、二次取消打断清理以及真实网络连接状态需要另行设计。不要把单元测试扩大成全环境保证。
6. 接入真实项目时保持什么
保留所有权层次,把模拟handler替换为真实接入前,还要重新核对地址、鉴权、目标能力与费用。本文不提供可以直接打到真实接口的默认配置,也不发起付费调用。
在长期服务里,可由应用生命周期创建和关闭客户端,在同一进程及受控异步环境中复用;每次请求的辅助函数负责响应。服务停机应停止接受新任务,再等待或按既定政策取消并收束在途任务,最后关客户端。本文不是完整服务器停机方案,未实现队列、限流、重试或状态落盘。
HTTPX异步指南说明客户端/响应关闭和手动流的责任;传输层文档说明MockTransport用于预设响应。本例只使用这些公开机制,没有依赖私有字段。
7. 常见错误与排查
- RuntimeError提示客户端关闭:检查所有者是否先于任务退出,或借用函数是否擅自关闭client。
- ExceptionGroup:查看组内异常类型和发生步骤,不能把整组当单个HTTP状态;本例不回显真实请求或上游正文。
- close_calls为0:确认测试确实进入流,再检查正常/异常/取消退出路径;未启动的流不能算清理通过。
- open_at_close大于0:说明本例传输关闭时仍有未关闭流,先修资源顺序,不通过删断言消除失败。
- “三个任务为什么不等于三个连接”:这里没有真实连接;任务、连接、客户端和业务配额分别核对。
8. 小练习与下期预告
新增一个“响应返回了无效JSON”的本地样本:让流正常结束但内容不合法,先断言解析失败后流已关闭、客户端仍打开,再修改实验。只用虚构内容,不记录真实日志和密钥。
核对清单:一个明确的客户端所有者;响应有独立范围;普通异常和取消样本都进入过流;任务收束先于客户端关闭;报告不包含未经测试的连接或性能结论。
下一篇讨论流式取消后怎样保留部分结果及完成状态。Python3.11 TaskGroup文档可用于核对异常传播规则。2026年10月30日已检索近期官方动态,本课采用常青实践,不把新模型公告当成本例或本站的新功能。欢迎评论一个虚构的资源关闭场景,不贴真实账号或请求数据。
本文基于Python 3.11和HTTPX 0.28.1,使用MockTransport构建离线可复现的异步资源关闭实验。重点验证客户端与响应流在正常执行、HTTP错误、异常抛出及任务取消等8种边界场景下的正确关闭行为。通过TaskGroup协调多任务、LabStream跟踪流关闭状态,并确保所有权层次清晰(客户端由外层main持有,响应流由各任务独立管理),避免提前关闭或遗漏清理。
409

被折叠的 条评论
为什么被折叠?



