我们的日志全是成功,对方却少了十二笔
目标
处理批量发送的请求中只有几笔失败的情形。把逐笔结果记录在我们这边,区分出要重发的失败和要修正的失败,只重发失败的部分,最后制作核对发送方和接收方的笔数与合计的对账表。
为什么重要
批量发送 API 会在对一个请求返回 200 的同时,把逐笔结果放在正文里。从 HTTP 的角度,200 是正确的——2xx 表示收到并接受了请求,而不是请求中的所有条目都成功了。 所以调用的一方如果只看状态码,失败的几笔就会在哪里都没有留下记录。没有错误,也没有告警,积累了好几天,在与对方的数字核对时才会被发现。 修复的顺序有四步。逐笔记录,把失败分成两类,只重发失败的部分,核对两边的数量。尤其是第三点很重要——如果把整批全部重发,已经进去的那些笔就会进去两次。重试的单位不是批,而是笔。 评分器不会相信你写的句子。评分器会把合作方服务器重新启动在评分器所选的端口上,让你的发件箱和发送器对着评分器的数据库实际运行,并与对方账本核对。
步骤
- 创建 /root/recon/receiver.py 并在端口 8010 上启动,用 /root/recon/gen_outbox.py 在 /root/recon/outbox.db 中生成 120 笔待发送的数据。
- 用 /root/recon/send.py 的
--naive只看状态码发送,然后把我们的记录与对方账本之间的差异写入 /root/recon/naive.json。 - 让 send.py 读取逐笔结果,以 status·reason·attempts 记录到
sent表中。 - 创建 /root/recon/classify.py,把失败原因分为要重发的和要修正的。
- 让 send.py 的
--retry只挑选可以重发的笔进行重发。 - 用 /root/recon/recon.py 核对发送方和接收方,生成 /root/recon/recon_result.json。
- 把剩余笔的处理计划写入 /root/recon/unresolved.json。
- 在 /root/recon/recon_report.md 中分四节进行汇报。
参考
- 合作方的运行契约:
python3 /root/recon/receiver.py --port <포트>(占位符为端口)。POST /batch接收{"batch_id": ..., "items": [{"item_id", "account", "amount"}]},并以 200 返回{"batch_id", "accepted", "rejected", "results": [{"item_id", "status", "reason"}]}。GET /ledger返回{"rows": [...], "count": n, "total": m}。 - 合作方拒绝的原因有四个。
invalid_amount(金额不是正整数)·unknown_account(已知账户只有 AC-01 到 AC-08)·duplicate(账本中已存在)·temporary_hold(金额为 97 的倍数的笔,只在第一次被暂时挂起)。判定顺序就是这个顺序。 - 发件箱的运行契约:
python3 gen_outbox.py [--db <경로>](占位符为路径)会生成outbox(item_id, account, amount, batch_id)的 120 笔数据和一个空的sent(item_id, status, reason, attempts)。这 120 笔中必须混有 5 笔账户错误的、3 笔金额为 0 的、4 笔金额为 97 的倍数的,并且分为 4 批,每批 30 笔。 - 发送器的运行契约:
python3 send.py --base <URL> --db <sqlite> [--batch-size 30] [--retry] [--naive]会返回{"sent": n, "accepted": n, "rejected": n, "by_reason": {...}, "http_ok": n}。默认发送尚未发送的笔,--retry只发送先前被拒绝的笔中可以重发的部分。--naive不读取逐笔结果,只看状态码,并且不在sent表中写入任何内容。 - 分类器的运行契约:
python3 classify.py --reason <이유>(占位符为原因)会返回{"retryable": true|false, "action": "requeue"|"fix_data"|"ignore"}。对于未知的原因,按安全的一侧(不重发)来应答。 - 比对器的运行契约:
python3 recon.py --base <URL> --db <sqlite> --out <결과 JSON>(占位符为结果 JSON)会返回outbox_items·accepted·rejected·not_sent·partner_rows·partner_total·our_accepted_total·only_ours·only_theirs·by_reason·unresolved·balanced。balanced在没有未发送的笔、双方都没有独有的笔、并且笔数与合计都对得上时为 true。 - 剩余笔的格式:
{"total": n, "by_action": {...}, "items": [{"item_id", "reason", "action", "owner"}]}。owner是人或团队的名称——没有负责人,那一笔就会永远留在那里。 - 常见错误:只看状态码;只记录失败的(与从未发送过的无法区分);把整批全部重发(会产生重复);只核对笔数而不看合计。
- 服务器要放在后台启动,等到
/health返回 200 之后再继续。评分器不会查看你启动的进程,而是直接重新启动脚本。
接收批量发送的合作方与 120 笔待发送数据
创建 /root/recon/receiver.py 并在端口 8010 上启动,再创建并运行 /root/recon/gen_outbox.py,在 /root/recon/outbox.db 中放入 120 笔数据。其中必须混有 5 笔账户错误的、3 笔金额为 0 的、4 笔金额为 97 的倍数的。
合作方对请求本身总是返回 200,并把失败放在正文的 results 中。拒绝的判定按金额、账户、重复、暂时挂起的顺序来看。发件箱要把待发送的和发送结果分成两张表——混在一张表里,就无法区分“从未发送过的”和“发送后失败的”。
只看状态码会漏掉什么
在 /root/recon/send.py 中创建 --naive,让它只看状态码,把全部都算作成功。把这个结果与对方账本的差异,以 batches·http_ok·assumed_sent·partner_rows·gap 写入 /root/recon/naive.json。
请求确实成功了。失败在正文里。我们的记录是 120 笔成功,而数一数对方账本中有多少行,差异就会原原本本地显现出来。这一步中不在 sent 表里写入任何内容。
把逐笔结果留在我们这边
让 send.py 读取响应正文中的 results,对每一笔在 sent 表中留下 status·reason·attempts。成功和失败都必须记录。第二次运行不应再次发送已经发送过的笔。
如果只写失败的,就无法区分“从未发送过的”和“发送后成功的”。最好不要假定响应中 results 的顺序与请求顺序相同,而是用 item_id 对应着读取。尚未发送的笔,就是在 outbox 中有而在 sent 中没有的笔。
要重发的与要修正的
创建 /root/recon/classify.py,让它把通过 --reason 接收的原因判定为 {"retryable": ..., "action": ...}。action 有 requeue·fix_data·ignore 三种,对于未知的原因,按不重发的一侧来应答。
暂时挂起的,原样重发就能解决。未知账户和错误金额,无论发送多少次都会得到同样的答复,所以必须由人来修正数据。已经进入对方账本的笔,既没有要重发的,也没有要修正的。如果没有这个分类,无法修正的那些笔就会在队列里永远打转。
不是按批,而是按笔重发
让 send.py 的 --retry 在先前被拒绝的笔中,只挑选可以重发的进行重发,并把结果更新到 sent 中。已经成功的笔绝对不能再次发送。
如果把整批全部重发,已经进去的那些笔就会进去两次。对方如果能防止重复,那是万幸,如果不能,就是我们造成了事故。请调用分类器,只挑出 retryable 的,并且要把 attempts 加上去,之后才能知道这是第几次尝试。
核对发送方和接收方
用 /root/recon/recon.py 对照发件箱、我们的记录和对方账本,生成 /root/recon/recon_result.json。不仅要看笔数,还要看合计以及双方独有的笔,只有全部对得上时,balanced 才是 true。
只核对笔数,就会漏掉“少了一笔、另一笔进去了两次”的情况。把我们记为成功的 id 集合与对方账本的 id 集合互相做减法,立刻就能看出哪些笔只在某一边。也要同时统计是否还有尚未发送的笔。
剩下的笔必须有负责人
在 /root/recon/unresolved.json 中,以 total·by_action·items(item_id·reason·action·owner)写出尚未解决的笔。action 必须与分类器的答复一致,owner 不能留空。
如果只列出“需修正 8 笔”,却不写谁来修正,这 8 笔就会永远留在那里。对账表的最后一栏是人或团队的名称。items 直接取出 sent 表中以被拒绝状态留下的那些笔来制作就可以。
对账报告
在 /root/recon/recon_report.md 中分为 ## 무엇을 놓치고 있었나 ## 건별 결과를 읽고 나서 ## 다시 보낼 것과 고칠 것 ## 대사표와 남은 것 四节来写(韩文,依次意为“漏掉了什么”“读取逐笔结果之后”“要重发的与要修正的”“对账表与剩余项”)。naive.json 和 recon_result.json 中的数字必须写进正文。
读者是那个说“我们的日志里全是成功啊”的人。请先说明,那份日志并没有说谎,而是只看了请求的状态码。然后再展示各原因的笔数和对账表中的两个数字即可。