リリースしたばかりの Rork アプリで、たまに「ログインし直してください」と表示されるという報告をもらいました。再現は難しく、特定の操作で必ず出るわけでもありません。手元で粘って観察すると、しばらくアプリを放置した後の最初の操作で出やすいと分かってきました。
正体は、アクセストークンの期限切れでした。Rork が生成した fetch は、トークンを付けて送るところまでは書いてくれますが、期限が切れたときに静かに更新して送り直す、という面倒までは見てくれません。期限切れがそのまま「認証エラー」として画面に出ていたのです。
個人開発で課金や同期を伴うアプリを運用していると、通信の堅さはレビューの評価に直結します。ここでは、Rork が出した素の fetch を土台に、トークン更新・再試行・冪等性を一つのクライアント層へ集約するまでの設計を、実装コードとともに残しておきます。
生成コードの fetch が抱える弱さ
Rork が最初に吐く通信コードは、おおむね次のような形に落ち着きます。
async function getProfile () {
const res = await fetch ( `${ API }/me` , {
headers: { Authorization: `Bearer ${ token }` },
});
return res. json ();
}
このコードには三つの弱点があります。第一に、トークンが切れても更新せず、エラーをそのまま返します。第二に、電波が一瞬切れただけの一時的な失敗でも、即座にあきらめます。第三に、送信ボタンの二度押しやタイムアウト後の再送で、同じ操作が二重に実行され得ます。これらを各画面でばらばらに対処すると、コードが散らかり、抜け漏れも生まれます。だからこそ、通信の面倒を見る層を 1 か所にまとめます。
トークンの自動更新を 1 か所に集約する
最初に解くのは、トークン切れです。サーバーが 401 を返したら、リフレッシュトークンで新しいアクセストークンを取り、元のリクエストをやり直します。
ここで落とし穴になるのが、同時多発のリクエストです。画面復帰の瞬間に複数の通信が一斉に 401 を受け取ると、それぞれが更新を走らせ、更新が何度も重なってしまいます。これを避けるため、更新中は 1 本の Promise を共有させます。
let refreshing : Promise < string > | null = null ;
async function refreshToken () : Promise < string > {
if ( ! refreshing) {
refreshing = fetch ( `${ API }/auth/refresh` , {
method: "POST" ,
body: JSON . stringify ({ refreshToken: store.refreshToken }),
})
. then (( r ) => r. json ())
. then (( d ) => {
store.accessToken = d.accessToken;
return d.accessToken as string ;
})
. finally (() => {
refreshing = null ;
});
}
return refreshing; // 同時に来た呼び出しは同じ更新を待ちます
}
refreshing を共有することで、何本のリクエストが同時に 401 を受けても、実際の更新は 1 回に収まります。私の環境では、この一手だけで「ログインし直してください」の報告がほぼ止まりました。
失敗を見分けて再試行する
トークン以外の失敗にも備えます。ただし、何でも再試行してよいわけではありません。再試行すべき失敗と、すぐにあきらめるべき失敗を見分けることが肝心です。
どのエラーを再試行するか
再試行する価値があるのは、一時的な失敗だけです。ネットワーク断、500 系のサーバーエラー、429(混雑)が対象です。逆に 400 や 404 のような「内容が間違っている」失敗は、何度送り直しても結果は変わらないので、再試行しません。
指数バックオフを入れる
再試行の間隔は、回を追うごとに広げます。一定間隔で叩き続けると、混雑しているサーバーをさらに追い込んでしまうからです。
async function withRetry < T >( fn : () => Promise < T >, max = 3 ) : Promise < T > {
let attempt = 0 ;
while ( true ) {
try {
return await fn ();
} catch (e) {
attempt ++ ;
if (attempt > max || ! isRetryable (e)) throw e;
const base = 300 * 2 ** (attempt - 1 ); // 300ms, 600ms, 1200ms
const jitter = Math. random () * 200 ; // 同時再試行をばらす
await new Promise (( r ) => setTimeout (r, base + jitter));
}
}
}
上限とゆらぎを忘れない
再試行には必ず上限を設けます。上限なしで粘ると、利用者を延々と待たせてしまいます。私は最大 3 回を目安にしています。あわせて、待ち時間に少しのゆらぎ(ジッター)を足します。複数の端末が同じ秒数で一斉に再試行すると、サーバーへ波が押し寄せるからです。本番でこの対処を入れてから、混雑時のエラー率が体感で 30% ほど下がりました。
冪等性キーで二重実行を防ぐ
再試行を入れると、新しい問題が生まれます。「サーバーは処理したのに、応答が届く前に通信が切れた」場合、再試行で同じ操作がもう一度実行され得ます。課金や投稿では、これが重複課金や二重投稿として表面化します。App Store や Google Play の課金を絡める場合はとくに、ここの綻びがそのまま返金対応や問い合わせにつながります。
防ぎ方は、リクエストごとに一意なキーを付けることです。サーバーは同じキーの二度目を「すでに処理済み」として受け流します。
function idempotencyKey () {
return `${ Date . now () }-${ Math . random (). toString ( 36 ). slice ( 2 ) }` ;
}
async function purchase ( itemId : string ) {
const key = idempotencyKey (); // 再試行しても同じキーを使い回します
return withRetry (() =>
apiFetch ( `/purchase` , {
method: "POST" ,
headers: { "Idempotency-Key" : key },
body: JSON . stringify ({ itemId }),
})
);
}
肝心なのは、再試行の間はキーを変えないことです。同じ操作の再送には同じキーを使い、別の操作には別のキーを使います。サーバー側の対応が必要ですが、課金まわりを扱うなら入れておくことを強く推奨します。私自身、ここを後回しにして重複課金の問い合わせに追われた経験があり、最初から仕込んでおく価値を痛感しました。
タイムアウトとキャンセル
最後に、応答が返ってこないケースに備えます。fetch は放っておくと永遠に待つので、AbortController で打ち切ります。
async function apiFetch ( path : string , init : RequestInit = {}) {
const ctrl = new AbortController ();
const timer = setTimeout (() => ctrl. abort (), 10000 ); // 10 秒で打ち切り
try {
return await fetch ( `${ API }${ path }` , { ... init, signal: ctrl.signal });
} finally {
clearTimeout (timer);
}
}
10 秒という値に絶対の正解はありません。私は、操作の性質に合わせて調整しています。画面遷移を伴う重い処理は長めに、ボタン一つの軽い操作は短めにします。打ち切ったあとに「もう一度お試しください」と静かに案内できると、利用者の不安をだいぶ和らげられます。
設計をどう運用に乗せるか
ここまでの要素を、役割ごとに整理しておきます。
課題 対処 集約する場所
トークン切れ 401 で自動更新・更新は 1 回に集約 クライアント層の共通処理
一時的な失敗 再試行+指数バックオフ+上限 withRetry ラッパー
二重実行 冪等性キーを付与 書き込み系リクエスト
無応答 タイムアウトで打ち切り apiFetch 共通処理
大事なのは、これらを各画面に散らさず、apiFetch のような 1 つの入口にまとめることです。入口が 1 つなら、後から方針を変えるときも 1 か所を直すだけで済みます。
まずどこから入れるか
全部を同時に入れる必要はありません。効果と手間のバランスで言えば、まずトークンの自動更新から入れるのが一番です。「ログインし直してください」は利用者の信頼を最も削るので、ここを止めるだけで印象が変わります。次に再試行、最後に課金まわりへ冪等性キー、という順序を私は勧めています。
手元の Rork プロジェクトで、まずは生の fetch 呼び出しが何か所に散っているかを数えてみてください。その数が、これから集約で得られる安心の大きさです。お読みいただきありがとうございました。