夜間バッチが毎回いくつか 401 で落ちる
目標
短い寿命のアクセストークンを持って、他社のAPIを呼び続けるクライアントを作ります。期限が切れる前にあらかじめ更新し、時計のずれの余裕を置き、401を1回だけ受け取って、更新後に再試行し、ワーカーが複数いても、更新が1回だけ起きるようにします。
なぜ重要なのか
「夜間バッチが毎回2、3件ずつ401で失敗します」の原因は、ほとんどの場合、同じです。トークンを一度受け取っておいて、401が来たらそのとき新しく受け取る構造は、期限が切れる瞬間ごとに、必ず1回は失敗します。 トークンが短く生きるのは、欠陥ではなく設計です。寿命が短ければ、漏れたときに使える期間が短くなります。そのため、直すべきなのは、サーバーではなく、こちらのクライアントです。 そして、直すときは、3つを一緒に見る必要があります。時計は正確に同じではないので、余裕が必要で、401がトークンの問題ではないこともあるので、再試行の回数に上限が必要で、ワーカーが複数いると、期限が切れる瞬間に更新が一度に押し寄せるので、更新を1回にまとめる必要があります。 採点ツールは、作成した文章を信じません。認証サーバーを、採点ツールが選んだポートで直接起動し、発行の回数とリソース呼び出しの回数を、サーバー側で数えて、作成したクライアントが、実際に何回更新し、何回かけ直したかを、突き合わせます。
ステップ
- /root/token/authsrv.pyを作成してポート8015で起動し、トークンを1つ受け取って、/root/token/token.jsonに保存してください。
- /root/token/decode.pyを作成して、トークンの中のヘッダーとペイロードを読み、/root/token/claims.jsonに書いてください。
- /root/token/should_refresh.pyを作成して、期限切れ・まだ有効でない・期限間近・正常の4つの分岐を、時計のずれの余裕まで考慮して見分けるようにしてください。
- /root/token/client.pyを作成して、期限が切れる前にあらかじめ更新しながら、何度呼び出しても、401が1件も出ないようにしてください。
- client.pyが401を受け取ったら、更新後にたった1回だけかけ直すようにしてください。
- client.pyが、ワーカーが複数同時に始まっても、トークンの発行が1回だけ起きるようにしてください。
- /root/token/token_policy.jsonに、ポリシー表を書いてください。
- /root/token/token_report.mdに、4つの節で報告してください。
参考
- 認証サーバーの実行契約:
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で答えるサーバーで、ループができます)、ロックだけかけて2回目の確認を抜かすこと、トークンをログにそのまま残すこと。
- サーバーはバックグラウンドで起動し、
/healthが200になるまで待ってから、次に進みます。採点ツールは、起動しておいたプロセスを見ず、スクリプトを直接起動し直します。
短く生きるトークンを受け取ってみる
/root/token/authsrv.pyを作成してポート8015で起動し、POST /oauth/tokenでトークンを1つ受け取って、応答の全体を/root/token/token.jsonに保存してください。
JWTは、base64urlでエンコードしたヘッダー・ペイロードと、HMAC署名を、ドットでつないだものです。Pythonの標準ライブラリのhmac・hashlib・base64だけで作れます。発行の数とリソース呼び出しの数をサーバーが数えておけば、あとでクライアントが実際に何回更新したかを確認できます。
トークンの中を開いてみる
/root/token/decode.pyを作成して、--inで受け取ったトークン応答からJWTを取り出してヘッダーとペイロードを読み、--outで/root/token/claims.jsonに、{"header": ..., "payload": ..., "ttl_s": exp - iat}を書くようにしてください。
JWTの前の2つの断片は、暗号ではなく、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は、こちら側に余裕を与えて見てこそ、受け取ったばかりのトークンが「まだ有効ではない」として拒否されません。期限間近の判定は、남은 시간 <= 여유(韓国語で「残り時間 <= 余裕」を意味する式です)の1行で済みます。remaining_sは、期限が切れたあとは負の数になる必要があります。
期限が切れる前にあらかじめ更新する
/root/token/client.pyを作成して、--calls回リソースを呼びつつ、毎回トークンが期限間近かを確認し、期限間近であれば、先に更新するようにしてください。トークンの寿命より長い時間にわたって呼び出しても、unauthorizedが0である必要があります。
前のステップの判定器をモジュールとして読み込んで使えば、ルールが1か所にだけ置かれます。トークンはファイルに入れておき、呼び出しの直前に「これを使ってよいか」を問うてください。寿命3秒のトークンで3秒を超えて呼び出せば、更新が実際に起きたかを、サーバーの発行の数で確認できます。
401は1回だけ受け取る
client.pyが401を受け取ったら、更新して、たった1回だけかけ直すようにしてください。更新しても401が続くなら、その呼び出しは失敗として終える必要があります。--start-expiredで、期限切れのトークンを持って始められる必要があります。
権限が足りない状況に、401を返すサーバーが多いです。そうしたサーバーで、再試行の回数に上限がなければ、更新と401が終わりなく繰り返されて、認証サーバーを叩き続けます。リソースの呼び出しが、呼び出し1回につき、ちょうど2回だけ出ているかを、サーバーの呼び出しの数で確認してください。
期限が切れる瞬間に、20個が一度に更新する
client.pyが、--workersで同時に複数の呼び出しを行っても、トークンの発行が1回だけ起きるようにしてください。キャッシュファイルとロックを使い、ロックを取ったあとに、キャッシュをもう一度確認する必要があります。
ロックだけかけても、更新が順番に並んで起きるだけで、回数はそのままです。待っている間に、ほかのワーカーがすでに更新しているかもしれないので、ロックを取ったあとにキャッシュを再び読んで、使えるトークンがあれば、それを使ってください。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行で残してください。余裕を寿命の何パーセントにしたか、なぜ再試行を1に縛ったかが入っていれば十分です。この表は、あとでパートナーが寿命を変えたときに、こちらが何を一緒に変えるべきかを知らせる文書でもあります。
トークンの点検報告書
/root/token/token_report.mdに、## 토큰이 어떻게 생겼나、## 언제 갈아야 하나、## 401 을 받으면 무엇을 하나、## 워커가 여럿일 때(韓国語の見出しで、順にトークンがどんな形か、いつ更新すべきか、401を受け取ったら何をするか、ワーカーが複数のとき、を意味します)の4つの節で書いてください。claims.jsonとtoken_policy.jsonの値が、本文に入っている必要があります。
読む人は、夜間バッチがなぜ401で死ぬのかを尋ねる人です。「トークンが期限切れになったから」で終わらせず、期限切れは正常であり、問題は、こちらが期限切れを扱う方法だったという点を、数字で見せてください。