"""Week 3 · M15 · w3_06 的测试(已写好,不要改)
用法:uv run pytest exercises/week3/test_w3_06_httpx.py -q
说明:全部用 httpx.MockTransport(handler) 假装服务器,**一个字节都不走网络**。
handler 是个普通函数:收到 httpx.Request,返回 httpx.Response。
"""
import asyncio
import time
from pathlib import Path
import httpx
import pytest
from w3_06_httpx import (
classify_error,
download,
fetch_many,
get_json,
get_json_retrying,
make_client,
stream_lines,
)
BASE_URL = "http://stub.local"
def handler(request: httpx.Request) -> httpx.Response:
"""假服务器:按路径返回不同响应。"""
path = request.url.path
if path.startswith("/items/"):
item_id = path.rsplit("/", 1)[-1]
return httpx.Response(200, json={"id": int(item_id), "name": f"物品{item_id}"})
if path == "/lines":
return httpx.Response(200, text="第一行\n\n第二行\n")
if path == "/big.bin":
return httpx.Response(200, content=b"x" * 1024)
if path == "/not-found":
return httpx.Response(404, json={"detail": "没有这个东西"})
if path == "/boom":
return httpx.Response(500, text="服务器内部错误")
if path == "/slow":
raise httpx.ReadTimeout("读超时", request=request)
return httpx.Response(404, text="unknown stub path")
def make_test_client() -> httpx.Client:
"""给同步测试用的客户端(MockTransport 版)。"""
return make_client(BASE_URL, timeout=1.0, transport=httpx.MockTransport(handler))
def make_async_test_client() -> httpx.AsyncClient:
"""给异步测试用的客户端(同一个 handler,MockTransport 两边都能用)。"""
return httpx.AsyncClient(
base_url=BASE_URL,
timeout=httpx.Timeout(1.0),
transport=httpx.MockTransport(handler),
)
def test_make_client_sets_base_url_timeout_and_headers() -> None:
with make_test_client() as client:
assert str(client.base_url) == BASE_URL
assert client.timeout.read == 1.0
assert client.headers["user-agent"] == "aggregator-exercise/1.0"
def test_get_json_returns_parsed_body() -> None:
with make_test_client() as client:
assert get_json(client, "/items/7") == {"id": 7, "name": "物品7"}
def test_get_json_raises_on_404() -> None:
with make_test_client() as client, pytest.raises(httpx.HTTPStatusError):
get_json(client, "/not-found")
def test_get_json_raises_on_500() -> None:
with make_test_client() as client, pytest.raises(httpx.HTTPStatusError):
get_json(client, "/boom")
async def test_fetch_many_returns_results_and_errors() -> None:
async with make_async_test_client() as aclient:
got = await fetch_many(aclient, ["/items/1", "/items/2", "/not-found"], limit=2)
assert got["/items/1"] == {"id": 1, "name": "物品1"}
assert got["/items/2"] == {"id": 2, "name": "物品2"}
assert isinstance(got["/not-found"], httpx.HTTPStatusError)
async def test_fetch_many_handles_empty_paths() -> None:
async with make_async_test_client() as aclient:
assert await fetch_many(aclient, [], limit=2) == {}
async def test_fetch_many_is_concurrent() -> None:
async def slow_handler(request: httpx.Request) -> httpx.Response:
await asyncio.sleep(0.02)
return httpx.Response(200, json={"path": request.url.path})
async with httpx.AsyncClient(
base_url=BASE_URL, transport=httpx.MockTransport(slow_handler)
) as aclient:
paths = [f"/items/{i}" for i in range(8)]
start = time.perf_counter()
got = await fetch_many(aclient, paths, limit=8)
assert len(got) == 8
assert time.perf_counter() - start < 8 * 0.02
async def test_stream_lines_skips_blank_lines() -> None:
async with make_async_test_client() as aclient:
assert await stream_lines(aclient, "/lines") == ["第一行", "第二行"]
async def test_stream_lines_raises_for_status() -> None:
async with make_async_test_client() as aclient:
with pytest.raises(httpx.HTTPStatusError):
await stream_lines(aclient, "/boom")
def test_get_json_retrying_recovers_after_two_503() -> None:
calls = 0
def flaky(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls < 3:
return httpx.Response(503, text="服务暂时不可用")
return httpx.Response(200, json={"ok": True, "calls": calls})
with httpx.Client(
base_url=BASE_URL, transport=httpx.MockTransport(flaky)
) as client:
assert get_json_retrying(client, "/items/1", attempts=3) == {
"ok": True,
"calls": 3,
}
assert calls == 3
def test_get_json_retrying_gives_up_and_raises() -> None:
calls = 0
def always_503(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(503, text="一直不可用")
with httpx.Client(
base_url=BASE_URL, transport=httpx.MockTransport(always_503)
) as client:
with pytest.raises(httpx.HTTPStatusError):
get_json_retrying(client, "/items/1", attempts=3)
assert calls == 3
def test_get_json_retrying_does_not_retry_404() -> None:
calls = 0
def not_found(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(404, text="没有")
with httpx.Client(
base_url=BASE_URL, transport=httpx.MockTransport(not_found)
) as client:
with pytest.raises(httpx.HTTPStatusError):
get_json_retrying(client, "/items/1", attempts=3)
assert calls == 1
def test_download_writes_file_and_returns_size(tmp_path: Path) -> None:
dest = tmp_path / "big.bin"
with make_test_client() as client:
size = download(client, "/big.bin", dest)
assert size == 1024
assert dest.stat().st_size == 1024
def test_download_raises_for_status(tmp_path: Path) -> None:
with make_test_client() as client, pytest.raises(httpx.HTTPStatusError):
download(client, "/boom", tmp_path / "nope.bin")
@pytest.mark.parametrize(
("exc", "expected"),
[
(httpx.ConnectTimeout("超时"), "timeout"),
(httpx.ReadTimeout("超时"), "timeout"),
(httpx.ConnectError("连不上"), "connect"),
(httpx.RequestError("其它网络问题"), "network"),
(
httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", BASE_URL),
response=httpx.Response(404),
),
"http_4xx",
),
(
httpx.HTTPStatusError(
"500",
request=httpx.Request("GET", BASE_URL),
response=httpx.Response(500),
),
"http_5xx",
),
(ValueError("别的错"), "unknown"),
],
ids=["连接超时", "读超时", "连接失败", "网络", "4xx", "5xx", "其他"],
)
def test_classify_error(exc: Exception, expected: str) -> None:
assert classify_error(exc) == expected
def test_timeout_from_transport_is_classified() -> None:
with make_test_client() as client:
try:
get_json(client, "/slow")
except Exception as exc:
assert classify_error(exc) == "timeout"
else:
raise AssertionError("应该抛超时异常")