分页、限流与配额

本节目标

学会完整而有边界地读取列表 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 契约。

服务端直接给出下一页 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

客户端策略:

  1. 优先读取目标 API 的限流文档;
  2. 若有合法 Retry-After 且未超过本次等待预算,等待完整建议时间;若它超出 deadline/等待预算,停止当前重试并交给外层延期,不能截短后提前请求;
  3. 若没有,使用带 jitter 的指数退避;
  4. 降低全局请求速率或并发,而不是让每个 worker 独立猛试;
  5. 设置最大尝试次数/总重试预算,耗尽后把任务转为可恢复失败;
  6. 记录状态码、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;只记录安全字段。

练习与自测

  1. 修改示例,使其保存最后一个 next_cursor 到本地状态文件;说明何时写入才不会跳页。
  2. 设计一个最多读取 500 条或 10 页(先到者为准)的循环。
  3. 画出 3 个 worker 共享一个全局速率限制器的流程。
  4. 如果处理第 4 页后进程崩溃,说明怎样避免下游出现重复记录。
  • 我能比较 offset 与 cursor 的权衡。
  • 我会为分页设置最大页数、重复 cursor 检测和 schema 校验。
  • 我能区分限流、配额和并发限制。
  • 我知道 429 不等于可以无限重试。

参考资料

获取日期:2026-07-22。下一步:超时、重试、退避与幂等性。