开始前,会这些就够了
- 能运行 Python,读懂字典、循环和 assert
- 先读工具调用与调用标识,理解结果要对应原请求
- 知道 HTTP 状态码与 JSON 正文字段是不同层的信息
助手一次查询三项资料,接口返回 HTTP 200。打开正文却发现:第一项找到了,第二项明确不存在,第三项暂时查不了。若只看外层状态,助手会误报“全部查完”;若按返回位置配对,还可能把第三项的失败算到第一项头上。
这次把原路线的调用标识原则做成可运行的批次实验:启动本机服务,发送六次真实 HTTP 请求,保留服务器接收记录和逐项结果。查询内容是固定夹具,没有真实账号、外部 API 或模型;目标是分清一封批次响应与里面各份答复分别证明什么。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- batch_lookup.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B batch_lookup.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实本机只读 HTTP 批次与虚构数据,逐项核对编号、状态及完整性;不连接远程服务、不自动重试,也不代表完整 Graph 协议。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先拆开外层信封和里面的答复
把批量请求想成一个大信封,里面放了三张查询单。外层 200 只按本题协议说明服务成功交回了这批响应;每张查询单仍有自己的状态。mixed 场景在同一份正文里同时放入 200、404 和 429,三个数字不能被外层那一个 200 覆盖。
Microsoft Graph 的批次说明明确区分这两层,并要求检查逐项状态。该页面还说明逐项答复顺序可以与请求不同。本例借用这一设计问题建立小协议,没有实现 Graph 的认证、依赖关系、限制或完整消息格式。
先按编号认领,再按用户顺序展示
三个请求依次叫 q1、q2、q3,分别查询 R1、R2、R3。服务器故意按 q3、q1、q2 返回。朴素的 zip 配对会让三个结果全部错位;脚本把这份错误配对也保留下来,便于先看到反例,再对照修正后的账本。
修正流程先建立请求编号索引,再检查返回编号,最后按原请求顺序输出。请求编号回答“这是哪次查询”,资源编号回答“查的是哪项资料”,两者各有用途。成功结果里也核对资源编号,避免一个看似成功的正文其实属于另一项资源。
本例的编号比较采用精确字符串相等,属于虚构协议约定;Graph 对批次标识的大小写有自己的规则。不能只复制字段名称就宣称兼容真实供应商。真实适配器应把标识比较规则写清,并单独验证,展示顺序则仍由调用方按原任务组织。
查无此项,与还没查清要分开
在这个明确约定的只读服务中,404 是一次已完成的否定答复,所以 q2 计入 resolved;429 则只表示本次无法给出查询结果,q3 仍未解决。mixed 的账本因此是两项已查清、一项未查清,其中真正找到资料的只有一项。
这些类别不是看到任何 404 都能照套的通用业务语义。有的接口会用状态码隐藏资源是否存在,需要按具体接口说明解释。本题只练固定的三种回复,不用空数组或空字符串抹平差别,也不会因暂时失败而编造一份“未找到”的资料。
429 没有触发重试。是否再次查询,还涉及真实服务的提示、任务时间预算和重试政策,应该另行决定;若换成写操作,更需要核对副作用与幂等条件。这个实验只记录已经收到的状态,不把它偷偷扩展成后续执行许可。
数量相同还不够,编号必须完整且唯一
missing 删掉 q2,duplicate 再塞入一个 q1,unknown 加入从未请求的 qX。收集器先核对这些结构问题,再接受逐项语义;不能简单拿响应长度等于请求长度当作已经一一对应,也不能遇到重复键就让字典保留最后一条。
本题遇到这类不可信批次结构,会保留原响应,但把整批 ledger 标为 unverified,不声称其中任何一项已完成验收。这是本地选择的保守政策,不是在说真实服务已经撤销了那些操作。对于本例只读数据,代价是暂缓使用;写入系统则还必须另查已发生的实际结果。
例如返回三条记录,也可能是两个q1加一个q3,q2依然缺席。只检查总数会让这一批过关;只建字典又可能把重复q1藏起来。应分别保留未知编号、重复编号、缺失编号三类问题,让调用方能说清为什么暂停使用,而不是只得到一个无法定位的失败标记。
批量两个字,不承诺同一种完成方式
Amazon SQS DeleteMessageBatch把成功与失败分别列出,也提醒即使 HTTP 200 仍应检查逐条错误。这支持“外层成功不足以证明全部成功”,但本项目没有删除消息,更没有模拟它的回执句柄或重投行为。
另一方面,Google Docs batchUpdate说明请求会先验证,无效请求会使整批不应用,并承诺该批更新一起原子应用;答复也与请求顺序对应。因此不能推导出“所有批量接口都会部分成功”或“所有返回都应忽略位置”,应读取各自契约。
运行六个 HTTP 批次,保存逐项账本
保存为 batch_lookup.py,在独立目录运行。脚本仅监听 127.0.0.1 的临时端口,使用固定 POST 正文承载三条只读查询;POST 在这里只是批次容器,服务没有业务写入。客户端关闭该次请求的代理使用,所有请求都留在本机。
输出 batch-lookup-report.json 与 batch-lookup-ledger.csv。服务自动启动并在结束时关闭;断言检查接收六个批次、乱序修正、部分结果、三种编号错误和外层 503。CSV 按场景与原请求顺序排列,JSON 另外保留原始响应和朴素错误配对。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Real loopback HTTP, fictional read-only batch contract; no model or remote API."""
from copy import deepcopy
import csv
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
from threading import Thread
from urllib.error import HTTPError
from urllib.request import Request, ProxyHandler, build_opener
REQUESTS = [{"id": "q1", "resource": "R1"}, {"id": "q2", "resource": "R2"}, {"id": "q3", "resource": "R3"}]
MODES = ("mixed", "all_found", "missing", "duplicate", "unknown", "outer_failure")
def fixture(mode):
rows = [{"id": "q3", "status": 429, "body": {"reason": "busy"}},
{"id": "q1", "status": 200, "body": {"resource": "R1", "name": "Tea"}},
{"id": "q2", "status": 404, "body": {"reason": "not_found"}}]
if mode == "all_found":
rows = [{"id": q["id"], "status": 200, "body": {"resource": q["resource"], "name": "Fixture"}}
for q in reversed(REQUESTS)]
if mode == "missing":
rows.pop()
if mode == "duplicate":
rows.append(deepcopy(rows[1]))
if mode == "unknown":
rows.append({"id": "qX", "status": 200, "body": {"resource": "RX", "name": "Other"}})
return {"responses": rows}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
mode = self.path.removeprefix("/")
request = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
assert request == {"requests": REQUESTS}
status = 503 if mode == "outer_failure" else 200
body = {"error": "unavailable"} if status == 503 else fixture(mode)
encoded = json.dumps(body).encode("utf-8")
self.server.seen.append({"mode": mode, "outer_status": status})
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def reconcile(requests, outer_status, payload):
requested = {q["id"]: q for q in requests}
if len(requested) != len(requests):
raise ValueError("duplicate_request_id")
problems, responses = [], {}
if outer_status != 200:
problems.append("outer_request_failed")
elif not isinstance(payload, dict) or set(payload) != {"responses"} or not isinstance(payload["responses"], list):
problems.append("invalid_envelope")
else:
for row in payload["responses"]:
if (not isinstance(row, dict) or set(row) != {"id", "status", "body"}
or not isinstance(row["id"], str) or type(row["status"]) is not int or not isinstance(row["body"], dict)):
problems.append("invalid_response_shape")
continue
key = row["id"]
if key not in requested:
problems.append("unknown_response_id:" + key)
elif key in responses:
problems.append("duplicate_response_id:" + key)
else:
responses[key] = row
problems.extend("missing_response_id:" + key for key in requested if key not in responses)
ledger = []
for request in requests: # Present results in request order, not response order.
row = responses.get(request["id"])
if problems:
verdict = "unverified"
elif row["status"] == 200:
verdict = "found" if row["body"].get("resource") == request["resource"] else "resource_mismatch"
elif row["status"] == 404:
verdict = "not_found" # Defined negative answer in THIS fictional contract.
elif row["status"] == 429:
verdict = "temporarily_unavailable"
else:
verdict = "unsupported_status"
ledger.append({**request, "status": row["status"] if row else None, "verdict": verdict})
resolved = sum(row["verdict"] in {"found", "not_found"} for row in ledger)
return {"status": "invalid_batch" if problems else ("resolved" if resolved == len(requests) else "incomplete"),
"requested": len(requests), "resolved": resolved, "unresolved": len(requests) - resolved,
"problems": problems, "ledger": ledger}
def main():
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.seen = []
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
opener = build_opener(ProxyHandler({}))
cases = []
try:
for mode in MODES:
request = Request(f"http://127.0.0.1:{server.server_port}/{mode}",
data=json.dumps({"requests": REQUESTS}).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
try:
response = opener.open(request, timeout=5)
except HTTPError as error:
response = error
with response:
status, payload = response.status, json.load(response)
cases.append({"mode": mode, "outer_status": status, "raw": payload,
"report": reconcile(REQUESTS, status, payload)})
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
assert not thread.is_alive() and len(server.seen) == 6
mixed = cases[0]["report"]
assert mixed["status"] == "incomplete" and mixed["resolved"] == 2 and mixed["unresolved"] == 1
assert [r["verdict"] for r in mixed["ledger"]] == ["found", "not_found", "temporarily_unavailable"]
naive = [{"request_id": q["id"], "paired_response_id": row["id"], "status": row["status"]}
for q, row in zip(REQUESTS, cases[0]["raw"]["responses"])]
assert all(r["request_id"] != r["paired_response_id"] for r in naive)
assert cases[1]["report"]["status"] == "resolved"
assert all(c["report"]["status"] == "invalid_batch" and c["report"]["resolved"] == 0 for c in cases[2:])
assert cases[-1]["outer_status"] == 503
assert cases[2]["report"]["problems"] == ["missing_response_id:q2"]
assert cases[3]["report"]["problems"] == ["duplicate_response_id:q1"]
assert cases[4]["report"]["problems"] == ["unknown_response_id:qX"]
wrong_resource = fixture("mixed")
wrong_resource["responses"][1]["body"]["resource"] = "R9"
assert reconcile(REQUESTS, 200, wrong_resource)["ledger"][0]["verdict"] == "resource_mismatch"
bad_status = fixture("mixed")
bad_status["responses"][0]["status"] = True
assert "invalid_response_shape" in reconcile(REQUESTS, 200, bad_status)["problems"]
try:
reconcile(REQUESTS + [REQUESTS[0]], 200, fixture("mixed"))
except ValueError:
pass
else:
raise AssertionError("duplicate request accepted")
output = {"scope": "six real loopback requests; fixed read-only fixtures; no retries",
"naive_position_pairing": naive, "server_seen": server.seen, "cases": cases, "assertions": "passed"}
Path("batch-lookup-report.json").write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
with Path("batch-lookup-ledger.csv").open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=["mode", "id", "resource", "status", "verdict"])
writer.writeheader()
for case in cases:
writer.writerows({"mode": case["mode"], **row} for row in case["report"]["ledger"])
for case in cases:
result = case["report"]
print(f"{case['mode']}: HTTP {case['outer_status']}; {result['status']}; resolved={result['resolved']}/3")
print("PASS: 6 loopback batches; item identity, status and completeness checked; no retries")
if __name__ == "__main__":
main()
怎样运行:Python 3.10+,在独立练习目录运行:python batch_lookup.py。仅标准库。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。真实本机只读 HTTP 批次与虚构数据,逐项核对编号、状态及完整性;不连接远程服务、不自动重试,也不代表完整 Graph 协议。
运行后对照结果
mixed: HTTP 200; incomplete; resolved=2/3
all_found: HTTP 200; resolved; resolved=3/3
missing: HTTP 200; invalid_batch; resolved=0/3
duplicate: HTTP 200; invalid_batch; resolved=0/3
unknown: HTTP 200; invalid_batch; resolved=0/3
outer_failure: HTTP 503; invalid_batch; resolved=0/3
PASS: 6 loopback batches; item identity, status and completeness checked; no retries报告要写明哪些查清、哪些还没有
先打开 mixed,沿 q1 回到返回数组里的第二项,再看 q2 的否定答复和 q3 的暂时不可用。随后打开 outer_failure:外层 503 没有可供核对的逐项回复,三项都保持未验收。不能把网络层失败自动翻译成三个资源都不存在。
交付给用户的摘要应保留这层结构,例如分别列出已找到、明确未找到和仍未解决的请求。程序只生成结构化账本,没有验证某个模型是否会如实转述;后续展示层应直接消费这些字段,不能从日志里抽一句“HTTP 200”重新推断结论。
检查报告时也要确认计数的分母。本题三表示本批提交的三个请求,不表示三家真实商户,更不是整个资料库的记录数量。六种场景各自重新提交同一份夹具,不能把它们合起来当成十八次独立业务调查;它们是在检查同一协议的不同故障出口。
实际 HTTP 证明了什么,还有哪些没做
2026-10-15 已在本机实际运行六组请求,全部内置断言通过,服务线程已结束。这里验证了客户端读取真实响应、分开处理外层状态、按编号关联以及拒绝不完整批次的流程,源码与文章嵌入一致。
它没有验证真实服务的身份、权限、网络延迟、生产吞吐量或写操作恢复。处理器只面向脚本自带的固定请求,不能作为通用公开服务器。资源内容也是人为固定的,批次已核对并不意味着真实世界的资料完整、最新或正确,报告范围必须停在已执行实验以内。
记住这三点
- 外层批次状态、逐项状态与业务结论分开记录。
- 先检查编号唯一、已知且完整,再按原请求顺序展示结果。
- 否定答案与未知结果不同;批次语义和是否重试都要依据实际接口约定。
别把例子用过头
- 把外层 HTTP 200 当成每项查询成功。
- 按返回位置关联原请求,或用字典覆盖重复编号。
- 看到 429 自动重试,或把本地保守拒绝误说成远端已经回滚。
自己试一小步
如果同一批查询里两次读取同一资源,你怎样保留两次查询各自的身份和结果?
- 自行设计四个请求,其中至少两个资源编号相同但请求编号不同,先写预期关联表。
- 让固定服务返回另一种顺序,并加入缺项、重复编号和逐项未知状态,观察报告出口。
- 制定逐项已完成、否定结果和未解决的定义,写出供用户阅读的摘要;不要默认增加重试。
- 交付真实本机HTTP日志、逐项CSV与原始JSON,说明为什么不能把固定夹具当成真实服务验收。
做到这些,就算完成
- 资源相同是否仍保留各自请求身份,显示顺序能否与原任务对应?
- 缺失、重复和未知编号是否得到明确处理,未解决项是否保留?
- 是否分别解释外层状态、逐项状态、否定答案与重试政策?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Microsoft Graph:JSON batching (新标签页)↗
核读 Creating a batch request、Processing the JSON batch response 与 Batch size limitations;支持标识关联、可能乱序、逐项状态及外层200的边界。
- Amazon SQS:DeleteMessageBatch (新标签页)↗
核读接口说明、Response Syntax、Response Elements及
BatchEntryIdsNotDistinct;支持成功失败混合报告与逐项编号。 - Google Docs:documents.batchUpdate (新标签页)↗
核读接口开头的验证、答复顺序、原子应用说明及Response body;作为不同批次保证的对照。