夜间批处理每次都有几笔死在 401
目标
制作一个带着短寿命访问令牌、持续调用别人 API 的客户端。在过期之前提前刷新,留出时钟误差的余量,只接受一次 401 并在刷新后重试,并且即使有多个工作进程,刷新也只发生一次。
为什么重要
“夜间批处理每次都有两三笔死在 401 上”,原因几乎总是相同的。先拿到一次令牌、等来了 401 才重新获取的结构,每到过期的瞬间,一定会失败一次。 令牌短命不是缺陷,而是设计。寿命必须短,一旦泄露,可被利用的时间才会短。所以需要修改的一方不是服务器,而是我们的客户端。 而且在修改时要同时关注三点。时钟不可能完全一致,所以需要余量;401 也可能不是令牌的问题,所以重试次数需要上限;如果有多个工作进程,刷新会在过期的瞬间一起涌来,所以必须把刷新合并为一次。 评分器不会相信你写的句子。评分器会把认证服务器启动在评分器所选的端口上,在服务器一侧统计签发次数和资源调用次数,核对你的客户端实际刷新了几次、重新发起了几次。
步骤
- 创建 /root/token/authsrv.py 并在端口 8015 上启动,获取一个令牌并保存到 /root/token/token.json。
- 创建 /root/token/decode.py,读取令牌内部的头部和负载,写入 /root/token/claims.json。
- 创建 /root/token/should_refresh.py,让它连同时钟误差的余量一起,区分出已过期、尚未生效、即将过期、正常这四种情况。
- 创建 /root/token/client.py,让它在过期之前提前刷新,即使多次调用,也一次 401 都不出现。
- 让 client.py 收到 401 时,刷新之后只重新发起一次。
- 让 client.py 即使多个工作进程同时启动,令牌的签发也只发生一次。
- 在 /root/token/token_policy.json 中写出策略表。
- 在 /root/token/token_report.md 中分四节进行汇报。
参考
- 认证服务器的运行契约:
python3 /root/token/authsrv.py --port <포트> [--ttl <초>] [--always-401](占位符依次为端口、秒数)。POST /oauth/token返回{"access_token": <JWT>, "token_type": "Bearer", "expires_in": <초>}(占位符为秒数),GET /api/data在Authorization: Bearer <토큰>(占位符为令牌)有效时返回 200,否则返回 401{"error": "invalid_token", "reason": ...}。GET /stats是{"issued": n, "api_requests": n}。JWT 为 HS256,claim 有 iss·sub·iat·nbf·exp·jti。 - 读取器的运行契约:
python3 decode.py --in <토큰 응답 JSON> --out <claims JSON>(占位符依次为令牌响应 JSON、claims JSON)会返回{"header": {...}, "payload": {...}, "ttl_s": exp - iat}。base64url 会去掉填充,所以要把长度补成 4 的倍数再解码。这里做的是读取,不是校验。 - 判定器的运行契约:
python3 should_refresh.py --exp <epoch> --now <epoch> --skew-s <초> [--nbf <epoch>](占位符为秒数)会返回{"valid": ..., "refresh": ..., "remaining_s": ..., "reason": "ok"|"near_expiry"|"expired"|"not_yet_valid"}。判定顺序是先 nbf,然后过期,然后即将过期。remaining_s是exp - now,过期之后为负数。即将过期(near_expiry)的令牌仍然可以使用。 - 客户端的运行契约:
python3 client.py --base <URL> --calls <n> [--interval-ms <m>] [--skew-s <s>] [--workers <w>] [--cache <파일>] [--start-expired](占位符为文件名)会返回{"calls": n, "ok": k, "unauthorized": u, "refreshes": r, "retried_after_401": x}。--workers大于 1 时会并发调用,--cache是保存令牌的文件,--start-expired会在缓存中放入已过期的令牌再开始。 - 防止刷新风暴的位置是缓存和锁。如果在获取锁之后不再确认一次缓存,锁就只是让大家排队,刷新的次数并不会减少。
- 策略表格式:
{"token_ttl_s": ..., "refresh_skew_s": ..., "max_retry_on_401": ..., "cache_path": ..., "stampede_guard": ..., "clock_sync": ..., "notes": ...}。refresh_skew_s至少为 1 且必须小于token_ttl_s,max_retry_on_401不超过 1。 - 常见错误:等到 401 来了才刷新;不给重试次数设上限(在把权限问题以 401 应答的服务器上会形成循环);只加锁而漏掉第二次确认;把令牌原样留在日志里。
- 服务器要放在后台启动,等到
/health返回 200 之后再继续。评分器不会查看你启动的进程,而是直接重新启动脚本。
拿一个短命令牌试试
创建 /root/token/authsrv.py 并在端口 8015 上启动,通过 POST /oauth/token 获取一个令牌,并把整个响应保存到 /root/token/token.json。
JWT 是把以 base64url 编码的头部、负载和 HMAC 签名用点连接起来的东西。只用 Python 标准库的 hmac、hashlib、base64 就能制作。如果由服务器统计签发数和资源调用数,之后就能确认客户端实际刷新了几次。
打开令牌看看里面
创建 /root/token/decode.py,使它从通过 --in 接收的令牌响应中取出 JWT,读取头部和负载,并通过 --out 把 {"header": ..., "payload": ..., "ttl_s": exp - iat} 写入 /root/token/claims.json。
JWT 的前两段不是密文,而是以 base64url 编码的明文。base64url 会去掉填充(=),所以解码之前要把长度补成 4 的倍数。请记住,这里做的是读取,不是校验——不能依据这些值来判断权限。
什么时候该更换
创建 /root/token/should_refresh.py,使它通过 --exp·--now·--skew-s·--nbf 进行判定。判定顺序是 nbf、过期、即将过期,reason 为 not_yet_valid·expired·near_expiry·ok。即将过期的令牌仍然可以使用,所以 valid 为 true。
时钟不可能完全一致。nbf 要在我们这一侧留出余量来看,刚拿到的令牌才不会被以“尚未生效”拒绝。即将过期的判定,只需要一行 남은 시간 <= 여유(韩文,意为“剩余时间 <= 余量”)。remaining_s 在过期之后必须是负数。
在过期之前提前更换
创建 /root/token/client.py,使它调用 --calls 次资源,每次都确认令牌是否即将过期,如果即将过期就先刷新。即使调用的时间比令牌寿命更长,unauthorized 也必须是 0。
把上一步的判定器作为模块引入使用,规则就只会存在于一个地方。把令牌保存在文件里,在调用之前问一句“这个还能用吗”。用寿命 3 秒的令牌连续调用超过 3 秒,就可以用服务器的签发数来确认刷新是否真的发生了。
401 只接受一次
让 client.py 收到 401 时刷新,并且只重新发起一次。如果刷新之后仍然是 401,该次调用就必须以失败结束。必须能够通过 --start-expired 带着已过期的令牌开始。
有许多服务器会在权限不足时返回 401。在这样的服务器上,如果重试次数没有上限,刷新和 401 就会无休止地重复,不停地冲击认证服务器。请用服务器的调用数确认,每次调用的资源请求是否恰好只发出了两次。
过期瞬间,二十个一起刷新
让 client.py 即使通过 --workers 同时进行多个调用,令牌的签发也只发生一次。要使用缓存文件和锁,并且在获取锁之后必须再确认一次缓存。
只加锁的话,刷新只是在排队发生,次数并没有变。等待期间可能别人已经更换好了,所以请在获取锁之后重新读取缓存,如果有可用的令牌就用它。在 Python 中使用 fcntl 的文件锁。
用策略表固定下来
在 /root/token/token_policy.json 中写入 token_ttl_s·refresh_skew_s·max_retry_on_401·cache_path·stampede_guard·clock_sync·notes。refresh_skew_s 至少为 1 且必须小于寿命,max_retry_on_401 不超过 1。
请用一行在 notes 中留下这些值的依据。写明余量取了寿命的百分之几,以及为什么把重试限制为 1,就可以了。这张表也是一份文档,在合作方更改了寿命时,它会告诉我们需要同时改什么。
令牌检查报告
在 /root/token/token_report.md 中分为 ## 토큰이 어떻게 생겼나 ## 언제 갈아야 하나 ## 401 을 받으면 무엇을 하나 ## 워커가 여럿일 때 四节来写(韩文,依次意为“令牌长什么样”“什么时候该更换”“收到 401 怎么办”“有多个工作进程时”)。claims.json 和 token_policy.json 中的值必须写进正文。
读者是那个在问夜间批处理为什么会死在 401 上的人。不要以“因为令牌过期了”收尾,请用数字表明:过期是正常的,问题在于我们处理过期的方式。