Python异步客户端资源关闭实验:MockTransport、TaskGroup与8项测试

本文基于Python 3.11和HTTPX 0.28.1,使用MockTransport构建离线可复现的异步资源关闭实验。重点验证客户端与响应流在正常执行、HTTP错误、异常抛出及任务取消等8种边界场景下的正确关闭行为。通过TaskGroup协调多任务、LabStream跟踪流关闭状态,并确保所有权层次清晰(客户端由外层main持有,响应流由各任务独立管理),避免提前关闭或遗漏清理。

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日已检索近期官方动态,本课采用常青实践,不把新模型公告当成本例或本站的新功能。欢迎评论一个虚构的资源关闭场景,不贴真实账号或请求数据。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值