uvicorn · Starlette · APIRouter · 路径 / 查询 / 请求体参数 · Depends · lifespan · HTTPException · 中间件 · OpenAPI

FastAPI 写接口:把前面学的装饰器、Pydantic、asyncio 串成一个服务

FastAPI 本身很薄:底下的 uvicorn 收字节,Starlette 管路由和中间件,FastAPI 只做一件事。精华:函数签名就是接口契约。FastAPI 在启动时读每个端点的参数名、类型标注和默认值,推出“每个参数从请求的哪里取、按什么规则校验、要先调哪些依赖”,再用同一份信息生成接口文档。Spring MVC 靠注解加容器扫描做的事,这里全由签名决定。不懂这一点的典型误用:参数写法差一点,来源就变了(比如 list[str] 不加 Query() 就被当成请求体);或者在每个请求里新建 HTTP 客户端、在 async def 里调同步库。

全景:一个 FastAPI 请求的一生 · 第 2、3、4、9 站地图:站 01 · FastAPI 写接口(对照 Spring MVC)以 FastAPI 0.162.2、Starlette 1.10.0、uvicorn 0.54.0 为例

①为什么学:值班助手要从一段脚本变成一个能被调用的服务

在全景第 2、3、4、9 站,这一步要解决的是:字节进来以后(第 2 站),找到该调的函数(第 3 站),把参数取出来校验好(第 4 站),结果变成 JSON 送回去(第 9 站)。前面几页把零件都讲了:装饰器登记路由、Pydantic 校验、asyncio 同时等。这一页把它们装成一个有路由分组、依赖、共享连接池、统一错误和中间件的真服务。

uvicorn字节 → scope 中间件计时、跟踪 ID 路由按登记顺序匹配 依赖 + 参数校验Depends、Pydantic 你的端点async def 响应模型校验、过滤 → JSON 第 2 站第 3 站第 4 站第 5~8 站第 9 站 Spring MVC 里 Tomcat 连接器 Filter HandlerMapping ArgumentResolver + @Valid @GetMapping 方法 MessageConverter 详细对照见 Spring MVC 请求处理流程 那一页;这一页只讲 FastAPI 一侧怎么写、为什么这样写。
需求工作要用:站 03 的告警分诊服务、站 07 的 MCP 服务端、站 10 的上线版,全是 FastAPI 服务。⑤ 的代码要能直接抄去用,⑥ 先讲最容易踩的坑。

②老办法:Spring MVC 你很熟,Python 这边的老办法是 Flask

Spring MVC 注解 + 容器
@RestController
@RequestMapping("/gc")
class GcController {
  @Autowired GcService svc;

  @GetMapping("/summary")
  GcSummary summary(
      @RequestParam String service,
      @RequestParam(defaultValue="10")
      @Min(1) @Max(60) int minutes) {
    return svc.summary(service, minutes);
  }
}
好用,但要容器扫描、要 Bean、要一堆注解;接口文档还要另配 springdoc。
Flask 能跑,全靠手写
@app.get("/gc/summary")
def summary():
    service = request.args.get("service")
    if not service:
        return {"error": "service required"}, 400
    try:
        minutes = int(request.args.get("minutes", 10))
    except ValueError:
        return {"error": "bad minutes"}, 400
    if not 1 <= minutes <= 60:
        return {"error": "1..60"}, 400
    ...
取参、转换、校验、报错全手写;同步模型,同时等 5 台机器要自己开线程。
FastAPI 签名即契约
@router.get("/summary", response_model=GcSummary)
async def gc_summary(
    service: str,
    minutes: Annotated[int, Query(ge=1, le=60)] = 10,
):
    ...
取参、转换、校验、422、接口文档,全从这几行签名推出来;async def 直接同时等。

③选谁:Python 写 Web 接口的四个常见选择

框架模型校验和文档适合
Flask同步(2.0 起能写 async,但仍是一个请求一个线程)自己加插件小工具、老项目
Django + DRF同步为主,带 ORM、后台、权限序列化器手写带后台和数据库的完整网站
FastAPIASGI,异步优先类型标注自动生成纯接口服务、AI 应用后端
LitestarASGI,异步优先类型标注自动生成和 FastAPI 同类,生态小一些

本页学 FastAPI。选择规则:只做接口、要大量等外部服务(大模型 API、日志代理)就选 FastAPI;要后台管理和 ORM 一体就选 Django。大模型 SDK 的示例、MCP 的 Python SDK、LangServe 这类工具,默认都配 FastAPI 或同一套 Starlette。

④原理:三层分工,签名决定一切,共享资源放在 lifespan 里

先知道这些
  • ASGI:Python 异步 Web 的调用约定,服务器调用 app(scope, receive, send),≈ Servlet 规范的 service(req, resp)。见全景第 2 站。
  • 前几页的三块零件:装饰器(@app.get 在 import 时登记)、Pydantic(入口校验、出口过滤)、asyncio(一个线程同时等)。这一页不再重讲。

1三层分工:uvicorn 收字节,Starlette 管路由,FastAPI 读签名

uvicorn(服务器)

监听端口,用 httptools 解析 HTTP,跑事件循环(装了 uvloop 就用它)。≈ Tomcat 的 Connector。

Starlette(Web 工具箱)

路由表、中间件、Request / Response、run_in_threadpool。≈ DispatcherServlet 加 Filter 链。

FastAPI(签名解释器)

启动时读每个端点的签名,建好“参数从哪取、依赖怎么调、怎么校验”的方案,再生成 OpenAPI。≈ 参数解析器 + Bean Validation + springdoc。

和 Spring 的差别:FastAPI 没有容器,不管对象的生命周期。它的“依赖注入”只发生在调用端点的那一刻,注入的是函数的返回值,不是单例 Bean。

2参数从哪来:看名字、看类型、看默认值 ≈ @PathVariable / @RequestParam / @RequestBody / @RequestHeader

签名里这样写FastAPI 认为来自Spring 里的写法
名字出现在路径里:/gc/{host} 配 host: str路径@PathVariable
简单类型(str、int、float、bool……)查询串@RequestParam
Pydantic 模型、或者 list 这类容器请求体@RequestBody
显式写 Query()、Header()、Cookie()、Body()按你写的对应的注解
Depends(fn)不来自请求,先调用 fn,取它的返回值参数解析器 / 请求作用域的 Bean

实验台:这个参数从哪来?答案取自本机 FastAPI 生成的 OpenAPI

先猜:

3Depends:每个请求现场调用一遍依赖函数 ≈ 参数解析器 + 请求作用域

get_client(request) return request.app.state.client get_hosts(service: str) 查不到 → HTTPException(404) 它自己的参数 service 也从查询串取 gc_summary(client, hosts, service, minutes) 依赖都成功,才轮到端点本身 同一请求里同一依赖只调一次 service=pay get_hosts 抛 404,端点不调 0.8 ms 返回 实测 测试时 app.dependency_overrides 把 get_client 换成假的
依赖可以再依赖别的依赖,FastAPI 按图依次调用。依赖函数里也可以写 yield:yield 之前是请求开始时做的事,之后是这次请求处理完的清理,≈ 请求级别的 try/finally(清理具体在响应发出前还是后,不同版本有调整,按所用版本的文档为准)。测试时用 dependency_overrides 换依赖,比上一页的 monkeypatch 稳得多,下一页 pytest 会用到。

4lifespan 和线程池:哪些东西整个进程共用一份 ≈ 单例 Bean、Tomcat 线程池

lifespan:启动时建,关闭时收

@asynccontextmanager
async def lifespan(app):
    app.state.client = httpx.AsyncClient(...)
    yield                       # 服务运行期间
    await app.state.client.aclose()

app = FastAPI(lifespan=lifespan)

httpx 客户端带连接池。共用一个,请求复用已经建好的 TCP 连接:本机打 200 次,每次新建客户端平均 10.30 ms,共用一个 1.77 ms 实测。≈ @Bean 加 @PreDestroy。

def 端点进线程池,上限 40

端点写法(各睡 1 秒)并发总耗时
async def + await asyncio.sleep501.08 s
def + time.sleep401.10 s
def + time.sleep502.08 s
async def + time.sleep55.01 s

本机 uvicorn 实测。普通 def 端点由 Starlette 交给 anyio 的线程池,默认上限 40 个线程(total_tokens 实测为 40),第 41 个起排队;async def 里阻塞则全部串行。

5为什么这样设计

FastAPI:签名是唯一的事实来源选了它

  • 参数、校验、依赖、文档只写一次,改了签名,文档跟着变,不会对不上。
  • 没有容器,启动快,函数就是普通函数,单独拿出来也能调用和测试。
  • 代价:规则是隐式的,写法差一点来源就变(实验台里的 list[str]);也没有容器帮你管对象的生命周期,共享资源要自己放进 lifespan。

Spring MVC:注解 + 容器没选

  • 每个参数都显式写注解,来源一目了然;容器管好所有 Bean 的创建和销毁。
  • 注解要在启动时反射扫描才生效;文档、校验、参数解析分在不同组件里配置。
  • Python 没有一个公认的 IoC 容器,照搬这套会很重。

决定胜负的约束:Python 3.5 以后类型标注已经普遍写在签名里,而 Pydantic 能在运行时读懂它们。既然“要什么”已经写在那儿了,再让人另写一遍注解或配置就是重复劳动。FastAPI 赌的就是这一点。

最少的语法:Spring MVC 怎么写,FastAPI 怎么写 细节放到 ⑥

Spring MVCFastAPI说明
@RestController @RequestMapping("/gc")router = APIRouter(prefix="/gc")再 app.include_router(router)
@GetMapping("/summary")@router.get("/summary")post、put、delete 同理
@PathVariable String host/{host} + host: str名字对上就行
@RequestParam @Min(1) int minutesminutes: Annotated[int, Query(ge=1)] = 10
@RequestBody @Valid GcQuery qq: GcQuery模型自动当请求体
@RequestHeader("X-Trace-Id")x_trace_id: Annotated[str | None, Header()] = None下划线自动变连字符
@Autowired GcService svcsvc: Annotated[GcService, Depends(get_svc)]每个请求调用 get_svc
throw new ResponseStatusException(404)raise HTTPException(404, detail=...)
@ControllerAdvice @ExceptionHandler@app.exception_handler(httpx.HTTPError)统一转换异常
Filter / HandlerInterceptor@app.middleware("http")拿到 request 和 call_next
@Bean + @PreDestroylifespan + app.state共享资源
java -jar app.jaruv run uvicorn main:app --port 8000生产加 --workers N
springdoc 的 /swagger-ui.html/docs、/openapi.json自带

⑤跑起来:值班助手的第一个真服务,外加一个假日志代理

还在 gc-helper 项目里。日志文件用 语法对照页的 make_logs.py 生成。先装依赖:

$ uv add fastapi "uvicorn[standard]" httpx
# uvicorn[standard] 会带上 uvloop 和 httptools,本机装到 uvicorn 0.54.0、uvloop 0.23.0
1agent.py 假日志代理:按路径返回日志,order-04 卡住不回
# 假日志代理:按路径返回 GC 日志,order-04 卡住不回
import asyncio
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse

app = FastAPI()
DELAYS = {"order-01": .12, "order-02": .08, "order-03": .15, "order-04": 3600, "order-05": .09}

@app.get("/{host}/gc.log", response_class=PlainTextResponse)
async def gc_log(host: str, minutes: int = 10):
    await asyncio.sleep(DELAYS.get(host, 0))
    return Path(f"logs/{host}.log").read_text()
2main.py 值班助手:路由分组、两个依赖、共享连接池、中间件、统一的模型
import asyncio, re, time
from contextlib import asynccontextmanager
from typing import Annotated

import httpx
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
from pydantic import BaseModel

AGENT = "http://127.0.0.1:9100"
SERVICES = {"order": [f"order-0{i}" for i in range(1, 6)]}
PAUSE = re.compile(r"Pause .* (\d+\.\d+)ms$")

class HostGc(BaseModel):
    host: str
    pauses: int = 0
    max_ms: float | None = None
    avg_ms: float | None = None
    error: str | None = None

class GcSummary(BaseModel):
    service: str
    minutes: int
    hosts: list[HostGc]
    elapsed_ms: int

@asynccontextmanager
async def lifespan(app: FastAPI):                 # ≈ @Bean + @PreDestroy:整个进程共用一个连接池
    app.state.client = httpx.AsyncClient(base_url=AGENT, timeout=5.0)
    yield
    await app.state.client.aclose()

app = FastAPI(title="值班助手", lifespan=lifespan)
router = APIRouter(prefix="/gc", tags=["gc"])     # ≈ 类上的 @RequestMapping("/gc")

def get_client(request: Request) -> httpx.AsyncClient:   # 依赖:从 app.state 取共享的客户端
    return request.app.state.client

def get_hosts(service: str) -> list[str]:                 # 依赖:校验 service 并查出主机列表
    if service not in SERVICES:
        raise HTTPException(status_code=404, detail=f"unknown service: {service}")
    return SERVICES[service]

Client = Annotated[httpx.AsyncClient, Depends(get_client)]
Hosts = Annotated[list[str], Depends(get_hosts)]

def parse(host: str, text: str) -> HostGc:
    ms = [float(m.group(1)) for line in text.splitlines() if (m := PAUSE.search(line))]
    if not ms:
        return HostGc(host=host, error="no pauses")
    return HostGc(host=host, pauses=len(ms), max_ms=max(ms), avg_ms=round(sum(ms) / len(ms), 1))

async def fetch(client: httpx.AsyncClient, host: str, minutes: int) -> HostGc:
    try:
        async with asyncio.timeout(1.0):
            r = await client.get(f"/{host}/gc.log", params={"minutes": minutes})
    except TimeoutError:
        return HostGc(host=host, error="timeout 1s")
    return parse(host, r.text)

@router.get("/summary", response_model=GcSummary)
async def gc_summary(client: Client, hosts: Hosts, service: str,
                     minutes: Annotated[int, Query(ge=1, le=60)] = 10):
    t = time.perf_counter()
    results = await asyncio.gather(*(fetch(client, h, minutes) for h in hosts))
    return GcSummary(service=service, minutes=minutes, hosts=results,
                     elapsed_ms=round((time.perf_counter() - t) * 1000))

@router.get("/{host}", response_model=HostGc)     # 写在 /summary 后面
async def gc_host(host: str, client: Client, minutes: Annotated[int, Query(ge=1, le=60)] = 10):
    return await fetch(client, host, minutes)

app.include_router(router)

@app.middleware("http")                            # ≈ Filter:每个请求都经过
async def timing(request: Request, call_next):
    t = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Process-Ms"] = f"{(time.perf_counter() - t) * 1000:.1f}"
    return response

@app.get("/healthz")
async def healthz():
    return {"ok": True}
$ uv run uvicorn agent:app --port 9100 &
$ uv run uvicorn main:app --port 8000 &
$ curl -i "localhost:8000/gc/summary?service=order&minutes=10"
HTTP/1.1 200 OK  x-process-ms: 1003.6
{"service":"order","minutes":10,"hosts":[{"host":"order-01","pauses":60,"max_ms":244.05,"avg_ms":26.1,"error":null},{"host":"order-02","pauses":60,"max_ms":248.1,"avg_ms":33.6,"error":null},{"host":"order-03","pauses":60,"max_ms":252.15,"avg_ms":31.6,"error":null},{"host":"order-04","pauses":0,"max_ms":null,"avg_ms":null,"error":"timeout 1s"},{"host":"order-05","pauses":60,"max_ms":260.25,"avg_ms":30.6,"error":null}],"elapsed_ms":1003}
$ curl -i "localhost:8000/gc/summary?service=pay"
HTTP/1.1 404 Not Found  x-process-ms: 0.8
{"detail":"unknown service: pay"}
$ curl -i "localhost:8000/gc/summary?service=order&minutes=0"
HTTP/1.1 422 Unprocessable Content  x-process-ms: 0.9
{"detail":[{"type":"greater_than_equal","loc":["query","minutes"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}
$ curl -i "localhost:8000/gc/order-02?minutes=5"
HTTP/1.1 200 OK  x-process-ms: 82.9
{"host":"order-02","pauses":60,"max_ms":248.1,"avg_ms":33.6,"error":null}
$ curl -i "localhost:8000/gc/summary/?service=order"
HTTP/1.1 307 Temporary Redirect  x-process-ms: 0.3
location: http://127.0.0.1:8000/gc/summary?service=order
$ curl -i "localhost:8000/healthz"
HTTP/1.1 200 OK  x-process-ms: 0.4
{"ok":true}

实测 本机 uv 0.13.7、CPython 3.13.18、FastAPI 0.162.2、Starlette 1.10.0、uvicorn 0.54.0、httpx 0.28.1,输出逐字复制(只保留了状态行、耗时头和响应体)。测完两个 uvicorn 都已停掉。

到这里已经能交差了:一个有路由分组、参数校验、依赖、共享连接池、统一错误和中间件的接口服务,接口文档自带。
已知还没处理的细节
  • /gc/{host} 如果写到 /summary 前面会怎样 → ⑥ 坑 1
  • 日志代理连不上时,httpx 抛的异常会变成 500 → ↺ 练习 3
  • 怎么给它写测试,不依赖真的日志代理 → 下一个知识点 pytest

⑥细节·规范·优化

第一圈 7 个坑,6 个在本机复现过 实测,第 7 个里 httpx 不跟随重定向那半句按官方文档。

第一圈

注意事项:不知道就会出事

1带变量的路由写在前面,把固定路径抢走← ④-1 按登记顺序匹配
现象
/gc/{host} 登记在 /gc/summary 前面,请求 /gc/summary 进了 gc_host,host="summary"。
根源
Starlette 按登记顺序逐条匹配,第一条能匹配上的就用;{host} 能匹配任何一段。
做法
固定路径写在前面;或者给变量加约束,比如 /gc/hosts/{host}。
2list[str] 参数不加 Query(),变成了请求体← ④-2 容器当请求体
现象
GET /d?hosts=a&hosts=b 返回 422,"loc":["body"],Field required。
根源
只有简单类型默认从查询串取,list 这类容器默认当请求体,而 GET 请求没有请求体。
做法
hosts: Annotated[list[str], Query()],才是 ?hosts=a&hosts=b。
3async def 端点里调同步库,所有请求串行← ④-4 事件循环线程
现象
端点里 time.sleep(1),5 个并发请求总共 5.01 s。
根源
async def 端点直接跑在事件循环上,阻塞调用占着那唯一的线程。详见 asyncio 页坑 1。
做法
端点里只要有同步库,就干脆写成普通 def,FastAPI 会放进线程池。
4普通 def 端点,第 41 个请求开始排队← ④-4 线程池上限 40
现象
各睡 1 秒,40 个并发 1.10 s,50 个并发 2.08 s。
根源
Starlette 用 anyio 的默认线程池跑同步端点,上限 40 个线程,再多就等。
做法
能改成 async 就改;确实要更多线程,在 lifespan 里调大 anyio.to_thread.current_default_thread_limiter().total_tokens;或者加 worker 进程。
5每个请求新建一个 HTTP 客户端← ④-4 lifespan
现象
每次 async with httpx.AsyncClient(),平均 10.30 ms 一次;共用一个 1.77 ms。
根源
新客户端就是新连接池,每次都要重新建 TCP 连接(HTTPS 还要再握手一次)。
做法
在 lifespan 里建一个,放进 app.state,用依赖取出来。调大模型 API 也一样。
6返回 {"error": ...},状态码却是 200设计如此
现象
端点 return {"error": "unknown service"},客户端收到 200。
根源
你返回什么,FastAPI 就序列化什么,默认状态码 200。调用方、监控、重试逻辑全把它当成功。
做法
raise HTTPException(404, detail=...);同一类错误用 @app.exception_handler 统一转换。
7路径多一个斜杠,收到 307设计如此
现象
/gc/summary/ 返回 307,location 指向不带斜杠的地址。curl 不加 -L 不会跟过去;按 httpx 文档,它默认也不跟随重定向,拿到的是空响应体。
根源
Starlette 的 redirect_slashes 默认开着,路径对不上时试着补上或去掉末尾斜杠再重定向。
做法
团队约定路径统一不带末尾斜杠;客户端拼地址别手写斜杠。
第二圈

使用规范:团队里怎么写

按业务拆 APIRouter

routers/gc.py、routers/alerts.py,main.py 只负责组装。

一个文件几十个端点,路由顺序互相打架。

依赖用 Annotated 起别名

Client = Annotated[httpx.AsyncClient, Depends(get_client)],端点里写 client: Client。

同一个 Depends 抄几十遍,改一处漏一处。

每个端点都有响应模型

response_model= 或返回类型标注成模型。

字段泄露,文档里没有返回结构。

共享资源只放 lifespan

HTTP 客户端、数据库连接池、加载好的模型。

每个请求重建连接;import 时就连外部服务。

测试换依赖,不换函数

app.dependency_overrides[get_client] = fake。

monkeypatch 改错模块,测试悄悄打了真网络。

生产环境管好 /docs

对外服务 FastAPI(docs_url=None, openapi_url=None),或者放在鉴权后面。

接口清单和参数全部公开。

第三圈

优化和深处

同一份签名,到了站 07 就是工具说明

/openapi.json 里每个参数的名字、类型、约束、是否必填都写好了。站 04 给 agent 定义工具、站 07 写 MCP 服务端时,需要的正是这样一份 JSON Schema:FastAPI 的“签名即契约”和“工具定义即契约”是同一个思路。

框架本身花多少时间

/healthz 从中间件进到出,x-process-ms 是 0.4 ms;带两个依赖、参数校验的 404 和 422 是 0.8~0.9 ms 实测。值班助手的 1 秒几乎全在等日志代理,框架开销可以忽略。真正要调的是连接池(坑 5)、线程池(坑 4)和 worker 数。

worker 数怎么定

一个 uvicorn 进程一个事件循环,只吃得满一个核。纯 IO 的服务,worker 数从 CPU 核数起步;有 CPU 活(解析大日志)的,CPU 活交给进程池或独立服务,别靠多开 worker 硬扛。容器里通常一个容器一个 worker,扩容交给 K8s,站 10 再讲。

版本差异

版本变化
0.93lifespan 参数,取代 @app.on_event("startup")
0.95推荐用 Annotated[...] 写 Query、Depends
0.100支持 Pydantic v2

网上和 AI 生成的老代码里常见 on_event 和 minutes: int = Query(10) 写法,还能用,新代码按上表写。

下一圈

  • 流式响应:StreamingResponse 和 SSE,把大模型的输出边生成边推(站 03)。
  • 后台任务和长任务:BackgroundTasks 够用到哪,什么时候该上队列(站 10)。
  • 鉴权:Security、OAuth2 依赖,对照 Spring Security(站 07、10)。
  • 中间件的顺序和纯 ASGI 中间件:对照 Filter 链。
  • Starlette 源码:Router.__call__ 怎么匹配,redirect_slashes 在哪。

↺回到⑤:拿⑥改进值班助手

1 · 加一个 POST 接口

加 POST /gc/compare,请求体是 {"service": "order", "hosts": ["order-01", "order-03"], "minutes": 10},只比较指定的几台。先猜 hosts 该怎么声明,再看 /docs 里它在哪。

用到:④-2、坑 2

2 · 换掉依赖测一次

不启动日志代理,用 app.dependency_overrides[get_client] 换成一个 httpx.AsyncClient(transport=httpx.MockTransport(handler)),让 TestClient 打 /gc/summary。

用到:④-3、规范“测试换依赖”;下一页 pytest 接着做

3 · 日志代理挂了怎么办

停掉 agent.py 再请求,看到 500。写一个 @app.exception_handler(httpx.ConnectError) 把它转成 502 和一句人能看懂的 detail。

用到:坑 6

1. 用一句话说出这一页的精华。

FastAPI 把函数签名当成接口契约:启动时读参数名、类型和默认值,推出每个参数从哪取、怎么校验、要先调哪些依赖,再用同一份信息生成接口文档;底下是 uvicorn 收字节、Starlette 管路由和中间件。

2. Flask 老办法痛在哪?Spring MVC 又是怎么解决的?

Flask 要手写取参、转换、校验、报错,每个接口重复一遍,而且同步模型。Spring 交给注解和容器,但要启动时扫描、文档另配。FastAPI 让类型标注一次性决定这些。

3. 坑 4 的“第 41 个请求开始排队”从 ④ 哪一条来?

④-4:普通 def 端点交给 anyio 的线程池执行,默认上限 40 个线程,超过的要等前面的线程空出来。

4. 预测:async def x(ids: set[int]) 和 async def y(q: GcQuery, minutes: int = 10),ids、q、minutes 各从哪来?

ids 是容器,默认当请求体;q 是 Pydantic 模型,请求体;minutes 是简单类型,查询串。

从 ④-2 的规则推出,可以在实验台里对照 list[str] 那一项。

5. 预测:把 get_hosts 里的 raise HTTPException(404) 改成 return [],service=pay 会得到什么?

200,hosts: [],elapsed_ms 接近 0。gather 空列表立刻返回,接口看起来“成功了”,调用方分不清是服务名写错还是这个服务真的没有机器。

从坑 6 推出:错误要用状态码表达,不要藏在“成功”的响应里。

6. 放回全景:第 2、3、4、9 站分别在 ⑤ 的代码里对应哪几行?如果把 lifespan 删掉、在 fetch 里每次新建客户端,整条链路会怎样?

第 2 站是 uvicorn main:app;第 3 站是 @router.get(...) 登记、include_router 装进路由表;第 4 站是 service: str、Query(ge=1, le=60) 和两个 Depends;第 9 站是 response_model=GcSummary。

每次新建客户端,第 6、7 站发出的 5 个请求都要重新建 TCP 连接,每个多花几毫秒(本机实测平均多 8.5 ms),HTTPS 时更多;并发一高,短连接还会大量堆在 TIME_WAIT 里。值班助手这一次的总耗时主要由 1 秒超时决定,所以单次请求看不太出来,压力上来才暴露。

见 全景第 6、7 站。