阶段3(上):Python/asyncio/FastAPI 专题的课程、企业场景、面试题和可运行示例
This commit is contained in:
1 parent
b8010a74f3
commit
7f52513ffc
24 files changed
+2817
-33
No files matched your search
@@ -0,0 +1,100 @@
|
||||
"""并发调用多个下游(例如大模型 / 检索 / 第三方 API):并发上限、单次超时、有限重试、部分失败、整体截止时间。
|
||||
|
||||
只用标准库。运行:python fanout.py;测试:python -m pytest -q test_fanout.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class RetryableError(Exception):
|
||||
"""可以重试的错误(限流 429、服务暂时不可用 503 等)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result(Generic[T]):
|
||||
key: str
|
||||
value: T | None = None
|
||||
error: str | None = None
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
async def call_with_retry(fn: Callable[[], Awaitable[T]], *, timeout: float, retries: int,
|
||||
base_delay: float = 0.05) -> tuple[T, int]:
|
||||
"""单次调用带超时;遇到超时或可重试错误时指数退避重试。返回 (结果, 尝试次数)。"""
|
||||
for attempt in range(1, retries + 2):
|
||||
try:
|
||||
async with asyncio.timeout(timeout): # Python 3.11+
|
||||
return await fn(), attempt
|
||||
except (TimeoutError, RetryableError):
|
||||
if attempt > retries:
|
||||
raise
|
||||
await asyncio.sleep(base_delay * 2 ** (attempt - 1) * (1 + random.random() / 2))
|
||||
raise AssertionError("不会执行到这里")
|
||||
|
||||
|
||||
async def fan_out(jobs: dict[str, Callable[[], Awaitable[T]]], *, concurrency: int, timeout: float,
|
||||
retries: int = 1, deadline: float | None = None) -> list[Result[T]]:
|
||||
"""并发执行所有任务,最多 concurrency 个同时进行;单个失败不影响其他任务。
|
||||
deadline:整体截止时间(秒),到时未完成的任务被取消并标记为超时。"""
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
results = {k: Result[T](key=k) for k in jobs}
|
||||
|
||||
async def run(key: str, fn: Callable[[], Awaitable[T]]) -> None:
|
||||
async def counted() -> T: # 记录尝试次数(失败时也能看到试了几次)
|
||||
results[key].attempts += 1
|
||||
return await fn()
|
||||
|
||||
async with sem:
|
||||
try:
|
||||
results[key].value, _ = await call_with_retry(counted, timeout=timeout, retries=retries)
|
||||
except asyncio.CancelledError:
|
||||
results[key].error = "整体超时,已取消"
|
||||
raise # 取消必须继续向上传播
|
||||
except Exception as e: # 单个任务失败只记录,不影响其他任务
|
||||
results[key].error = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
||||
|
||||
tasks = [asyncio.create_task(run(k, fn)) for k, fn in jobs.items()]
|
||||
done, pending = await asyncio.wait(tasks, timeout=deadline)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True) # 等待取消完成,避免任务泄漏
|
||||
return [results[k] for k in jobs]
|
||||
|
||||
|
||||
# ---------------- 演示 ----------------
|
||||
def fake_llm(name: str, delay: float, fail_times: int = 0) -> Callable[[], Awaitable[str]]:
|
||||
state = {"left": fail_times}
|
||||
|
||||
async def call() -> str:
|
||||
await asyncio.sleep(delay)
|
||||
if state["left"] > 0:
|
||||
state["left"] -= 1
|
||||
raise RetryableError("429 Too Many Requests")
|
||||
return f"{name} 的回答"
|
||||
|
||||
return call
|
||||
|
||||
|
||||
async def demo() -> None:
|
||||
jobs = {
|
||||
"摘要": fake_llm("摘要", 0.10),
|
||||
"分类": fake_llm("分类", 0.10, fail_times=1), # 第一次被限流,重试后成功
|
||||
"翻译": fake_llm("翻译", 2.00), # 太慢,单次超时
|
||||
"关键词": fake_llm("关键词", 0.05),
|
||||
}
|
||||
start = time.perf_counter()
|
||||
for r in await fan_out(jobs, concurrency=2, timeout=0.5, retries=1):
|
||||
print(f"{r.key}: {r.value or r.error}(尝试 {r.attempts} 次)")
|
||||
print(f"总耗时 {time.perf_counter() - start:.2f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(demo())
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from fanout import RetryableError, fake_llm, fan_out
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_concurrency_limit():
|
||||
active, peak = 0, 0
|
||||
|
||||
def job():
|
||||
async def call():
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
await asyncio.sleep(0.05)
|
||||
active -= 1
|
||||
return "ok"
|
||||
return call
|
||||
|
||||
res = run(fan_out({str(i): job() for i in range(10)}, concurrency=3, timeout=1))
|
||||
assert peak == 3 and all(r.value == "ok" for r in res)
|
||||
|
||||
|
||||
def test_retry_then_success():
|
||||
[r] = run(fan_out({"a": fake_llm("a", 0.01, fail_times=1)}, concurrency=1, timeout=1, retries=1))
|
||||
assert r.value == "a 的回答" and r.attempts == 2
|
||||
|
||||
|
||||
def test_partial_failure():
|
||||
async def boom():
|
||||
raise ValueError("参数错误") # 不可重试的错误:只尝试一次
|
||||
|
||||
res = run(fan_out({"ok": fake_llm("ok", 0.01), "bad": boom, "slow": fake_llm("slow", 1)},
|
||||
concurrency=3, timeout=0.1, retries=1))
|
||||
by = {r.key: r for r in res}
|
||||
assert by["ok"].value == "ok 的回答"
|
||||
assert by["bad"].error == "ValueError: 参数错误"
|
||||
assert by["slow"].error == "TimeoutError"
|
||||
|
||||
|
||||
def test_retry_exhausted():
|
||||
[r] = run(fan_out({"x": fake_llm("x", 0.01, fail_times=5)}, concurrency=1, timeout=1, retries=2))
|
||||
assert r.value is None and "RetryableError" in r.error
|
||||
|
||||
|
||||
def test_overall_deadline_cancels():
|
||||
start = time.perf_counter()
|
||||
res = run(fan_out({"fast": fake_llm("fast", 0.01), "slow": fake_llm("slow", 5)},
|
||||
concurrency=2, timeout=10, deadline=0.2))
|
||||
assert time.perf_counter() - start < 1
|
||||
by = {r.key: r for r in res}
|
||||
assert by["fast"].value and by["slow"].error == "整体超时,已取消"
|
||||
|
||||
|
||||
def test_retryable_error_is_exception():
|
||||
assert issubclass(RetryableError, Exception)
|
||||
Reference in new issue
Block a user