分页、限流与配额
本节目标
学会完整而有边界地读取列表 API,理解 offset/page、cursor 和 link 三类分页;区分限流与配额;遇到 429 时尊重服务端提示,而不是并发重试制造“惊群”。
为什么 API 不一次返回全部数据
假设知识库有 100 万条文档。一次返回全部结果会占用服务端内存、网络带宽和客户端内存,也难以在中途失败后恢复。分页把结果拆成多个有限响应。代价是:你必须正确保存下一页位置、处理数据变化,并为循环设置终止边界。
三种常见分页
Page / offset 分页
GET /items?page=3&page_size=50
GET /items?offset=100&limit=50优点是容易跳到指定位置,适合相对稳定的小数据集。若读取过程中前面插入或删除数据,offset 可能造成重复或漏读。服务端对很深的 offset 查询也可能昂贵。
Cursor 分页
{
"items": [{"id": "a1"}, {"id": "a2"}],
"next_cursor": "opaque-value"
}JSON 不支持合法的行尾注释:items 是当前页资源数组,next_cursor 是服务端签发的不透明续页位置;客户端只能原样回传它,不能从字符串内容推断页号或自行修改。
下一次把不透明 cursor 原样传回:
GET /items?cursor=opaque-value&limit=50客户端不应解析或修改 cursor。它通常比 offset 更适合持续变化的数据,但是否保证快照一致、cursor 多久过期,完全取决于 API 契约。
Link / next URL 分页
服务端直接给出下一页 URL,可能位于 JSON 的 next 字段或标准化程度不同的 Link header 中。客户端应先验证下一页 URL 是否仍属于预期 host,再携带合适凭据,避免把 Authorization 意外发往不可信主机。
没有统一字段名
next_cursor、has_more、page_token、next和Link都可能出现。分页参数与终止条件必须以目标 API 官方文档为准。
一个防失控的 cursor 循环
from collections.abc import Iterator # 标注函数会按需逐项产生数据,而不是先把全部页装入内存。
from typing import Any # 表示 JSON object 中的字段值可以是多种合法 JSON 类型。
import requests # 使用 Requests 发送分页请求并区分其 JSON 解码异常。
def parse_json_object(response: requests.Response) -> dict[str, Any]: # 验证一个 HTTP 响应是否满足“顶层 JSON object”这一最小分页契约。
response.raise_for_status() # 先拒绝 4xx/5xx,避免把错误页解析成成功分页数据。
media_type = ( # 提取不含 charset 参数的主 media type。
response.headers.get("Content-Type", "") # header 缺失时以空字符串参与后续统一拒绝。
.split(";", 1)[0] # 将 `application/json; charset=utf-8` 截成主类型。
.strip() # 清除服务端可能附带的空白。
.lower() # 统一大小写后再比较。
)
if media_type != "application/json" and not media_type.endswith("+json"): # 同时兼容 problem+json 等供应商扩展 JSON 类型。
raise ValueError("预期 JSON 响应") # 不让 HTML/文本错误页进入 JSON 解码阶段。
try: # JSON 解码可能仍失败,例如响应体被网关截断。
value = response.json() # 调用 Requests 的 JSON 解码器得到 Python 值。
except requests.exceptions.JSONDecodeError as exc: # 捕获库定义的 JSON 解析异常。
raise ValueError("响应声明为 JSON,但内容无法解析") from exc # 保留异常因果链,同时给上层稳定的课程错误。
if not isinstance(value, dict): # 分页协议要求顶层必须是 object,不能只返回数组或字符串。
raise ValueError("分页响应必须是 JSON object") # 先拒绝形状错误,后续才能安全读取字段。
return value # 返回仅通过最低协议检查的 object,字段级校验由调用方完成。
def iter_items( # 定义带页数、条目数和重复 cursor 三重保护的分页生成器。
base_url: str, # 由可信配置提供的 API 基础地址。
*, # 后续限制参数必须以关键字传入,减少位置写错风险。
max_pages: int = 100, # 限制最多请求的页面数,防止服务端永不终止。
max_items: int = 1_000, # 限制总产出条目数,防止单页异常膨胀。
) -> Iterator[dict[str, Any]]: # 每次 yield 一个已通过最低形状检查的 item object。
if type(max_pages) is not int or max_pages < 1: # bool 是 int 子类,因此这里用 type 做严格检查。
raise ValueError("max_pages 必须是正整数") # 不允许 0、负数、浮点或布尔页数上限。
if type(max_items) is not int or max_items < 1: # 同样严格校验总条目上限。
raise ValueError("max_items 必须是正整数") # 无效预算不能悄悄替换成默认值。
cursor: str | None = None # 第一页通常没有 cursor;后续页使用服务端原样返回的值。
seen_cursors: set[str] = set() # 保存已收到的下一页 cursor,用于检测循环。
yielded = 0 # 累计已经交给调用方的条目数。
with requests.Session() as session: # 本函数创建 Session,也由 with 在生成结束或异常时关闭它。
for _ in range(max_pages): # 最多发起 max_pages 次请求,达到上限仍未结束就失败。
params = {"limit": 50} # 请求一个由本例明确设定的单页大小。
if cursor is not None: # 第一页不发送 cursor,后续页才附上服务端签发的值。
params["cursor"] = cursor # 原样回传 cursor,不能解析、递增或自行改写它。
response = session.get( # 使用连接池请求当前页。
f"{base_url.rstrip('/')}/items", # 去掉尾斜杠后拼接固定资源路径。
params=params, # 让 Requests 负责 query 编码。
headers={"Accept": "application/json"}, # 明确本客户端只接受 JSON 表示。
timeout=(3.05, 15), # 每页都有连接与读取超时,避免单页无限阻塞。
)
page = parse_json_object(response) # 在读取 items 前验证 HTTP、media type 与顶层 JSON 形状。
items = page.get("items") # 读取协议约定的当前页资源数组。
if not isinstance(items, list): # 字段缺失或类型漂移都不是空页。
raise ValueError("响应缺少 items 数组") # 显式停止,避免错误契约造成静默漏数据。
for item in items: # 逐项处理当前页,保持流式内存占用。
if not isinstance(item, dict): # 本例的 item 必须是 JSON object。
raise ValueError("items 必须只包含 JSON object") # 标量/数组项需要不同的业务解析器。
if yielded >= max_items: # 先检查总预算,避免交还超出调用方承受范围的数据。
raise RuntimeError(f"超过最大条目数 {max_items}") # 预算耗尽属于受控失败,而不是继续吞内存。
yielded += 1 # 只在即将交还合法 item 时增加已处理计数。
yield item # 让调用方可以边消费边处理,不必积累整个列表。
next_cursor = page.get("next_cursor") # 读取服务端给出的下一页位置。
if next_cursor is None: # 本例以 null 表示正常终止。
return # 没有下一页时结束生成器并让 Session 自动关闭。
if not isinstance(next_cursor, str) or not next_cursor: # 空字符串或非字符串 cursor 都不可信。
raise ValueError("next_cursor 格式错误") # 不以猜测的默认位置继续请求。
if next_cursor in seen_cursors: # 服务端重复 cursor 会导致无限循环和重复导入。
raise RuntimeError("服务端返回了重复 cursor,停止循环") # 立即停止并让上层排查服务端契约。
seen_cursors.add(next_cursor) # 记录新 cursor,下一轮用来检测重复。
cursor = next_cursor # 把它作为下一页唯一的续页令牌。
raise RuntimeError(f"超过最大页数 {max_pages}") # 页数耗尽仍未收到终止 cursor 时 fail closed。关键保护:
max_pages防止契约错误造成无限循环;max_items防止“页数有限、单页却异常膨胀”或下游处理无上限;- 记录已见 cursor,发现重复立即停止;
- 先校验成功状态、JSON media type 与顶层 object,再校验
items、元素与 cursor 类型; - 使用生成器逐条消费,避免先把全部页装进内存;
- 若任务要断点续传,将下一页 cursor 和处理进度安全持久化。
即使 cursor 分页也可能返回重复记录。对导入任务可基于稳定资源 ID 去重,并明确“重复跳过”与“更新已有记录”的业务规则。
限流与配额不是一回事
- 限流(rate limit):限制短时间内请求速率或并发,例如每分钟请求数。
- 配额(quota):限制一个较长周期的累计用量,例如每月请求量、token 或费用预算。
- 并发限制:同一时间允许的在途请求数。
不同 API 可能按账户、项目、模型、用户、IP 或 endpoint 分桶。响应 header 名和计数算法没有统一约定,不能假设一定存在 X-RateLimit-Remaining。
处理 429
RFC 6585 定义 429 Too Many Requests,响应可能带 Retry-After。该 header 可能是秒数,也可能是 HTTP 日期:
HTTP/1.1 429 Too Many Requests
Retry-After: 30客户端策略:
- 优先读取目标 API 的限流文档;
- 若有合法
Retry-After且未超过本次等待预算,等待完整建议时间;若它超出 deadline/等待预算,停止当前重试并交给外层延期,不能截短后提前请求; - 若没有,使用带 jitter 的指数退避;
- 降低全局请求速率或并发,而不是让每个 worker 独立猛试;
- 设置最大尝试次数/总重试预算,耗尽后把任务转为可恢复失败;
- 记录状态码、endpoint 模板、尝试次数、等待时间和 request ID,不记录 token。
如果 429 是“月度配额已用尽”,等几秒重试没有意义。机器错误码、重置时间和管理后台指标能帮助区分短期限流与长期配额。
主动限速
等到 429 才减速会浪费请求。批处理可采用:
- 固定 worker 数限制并发;
- 令牌桶或漏桶控制全局速率;
- 根据服务端剩余额度动态减速(只有文档明确 header 语义时);
- 把交互请求与后台批处理放入不同队列;
- 对同一资源合并重复请求,使用缓存或批量 endpoint。
Agent 循环尤其要防止模型反复选择同一工具。API 客户端的限速只能缓解后果,Agent 层还应限制单次任务的工具调用次数和总预算。
分页与限流组合时的恢复
每成功处理一页后再保存 cursor。若在下一页遇到 429 或临时 5xx,重试当前页;若进程重启,从已保存 cursor 继续。写入下游系统时还要保证重复处理安全,例如用源资源 ID 做 upsert。
获取一页 -> 校验 -> 写入/处理 -> 保存 next_cursor -> 获取下一页
^ 成功后才推进检查点常见错误
- 看到
items为空就总是终止:有的契约以next_cursor为准;按文档确定。 - cursor 重复仍继续循环。
- 先收集所有页再处理,导致内存峰值和失败后全部重来。
- 每个线程各自重试 429,瞬间形成更多请求。
- 将所有 429 当作短期速率限制,忽略配额耗尽。
- 日志打印完整 next URL,而该 URL 可能包含敏感签名 query;只记录安全字段。
练习与自测
- 修改示例,使其保存最后一个
next_cursor到本地状态文件;说明何时写入才不会跳页。 - 设计一个最多读取 500 条或 10 页(先到者为准)的循环。
- 画出 3 个 worker 共享一个全局速率限制器的流程。
- 如果处理第 4 页后进程崩溃,说明怎样避免下游出现重复记录。
- 我能比较 offset 与 cursor 的权衡。
- 我会为分页设置最大页数、重复 cursor 检测和 schema 校验。
- 我能区分限流、配额和并发限制。
- 我知道 429 不等于可以无限重试。
参考资料
获取日期:2026-07-22。下一步:超时、重试、退避与幂等性。