2分で読了エンジニアリング

URL短縮API: レート制限、リトライ、冪等性

URL短縮APIを本番環境で呼び出す方法。トークンバケット方式のレート制限、バックオフ付きリトライが必要なステータスコード。

Marius Voß
DevRel · edge infra

3つのエンドポイント、認証ヘッダー、JSONボディ。URL短縮APIはバックログの中でも最も簡単な統合の一つであり、クイックスタートで数分後には動作する短縮リンクが得られる。クイックスタートが省略しているのは、統合が量産環境で動き始めたときに起きるすべてのことだ。レートリミッターが押し返してくる、バッチ処理の途中でで一時的な 503 が発生する、同じメッセージを2回配信するジョブキューがある。これらを誤ると、重複リンク、失われた処理、そして事態を悪化させる 429 の嵐が生まれる。

この記事はAPIクイックスタートの本番環境向け補足だ。デモと信頼性の高い統合を分ける3つのメカニズムを扱う。レート制限とそれに合わせたペーシング、リトライすべきエラーとバックオフの方法、そしてリトライが2つ目のリンクを作らないようにする冪等性キーだ。例はElidoのAPIを使うが、パターンはよく設計されたリンク短縮APIなら同じだ。短縮リンクをコードで管理するインフラとして扱う場合、その広い文脈はTerraformとしての短縮リンクで説明している。

レート制限: トークンバケットとRetry-Afterヘッダー

Elidoはワークスペース単位でスコープされたトークンバケットでAPIを計量する。持続レートはFreeが毎秒10リクエスト、Proが毎秒30、Businessが毎秒100で、バースト容量はそれぞれ20、60、200だ。たとえばProでは、満杯のバケットなら60リクエストを一度に送れ、その後レートは持続的な毎秒30に落ち着く。その上にAPIキーごとの別バケットがあり、うるさい1つのキーがワークスペース全体を食い尽くすことはない。ほとんどのリンク作成ジョブはバーストに収まり、制限を感じることすらない。

制限がいつ働くかを推測する必要はない。拒否されたリクエストは3つのシグナルを運ぶ。

  • Retry-After - 再試行までに待つ秒数(現在は 1)。
  • X-RateLimit-Scope - どのバケットが働いたか: workspace または api_key。
  • JSONボディ - {"error":"workspace rate limit exceeded"}、制限の種類を示す。

残りトークンを示すヘッダーはないため、ペース配分はクライアント側で行う。並行度をプランの持続レート以下に保ち、最初の 429 で止まって Retry-After を待つ。拒否の壁に突撃してはいけない。先読みのペーシングはスループットをなめらかに保つ。拒否のたびに盲目的にリトライするとラウンドトリップを無駄にし、全クライアントが同じ瞬間にリトライすれば雪崩を引き起こす。

ワークスペースのレートで補充されるトークンバケットと、リクエストによるトークン消費、バケットが空になったときに返るRetry-Afterヘッダー付きの429レスポンス

多数のリンクを作成する必要がある場合、単一作成エンドポイントをループしてはいけない。POST /v1/workspaces/{workspace_id}/links/bulk は1リクエストに最大100リンクを受け付ける。1回のバルク呼び出しで100リンクを1往復で移せるが、100回の単一呼び出しは100トークンとバーストの大半を消費する。Google Sheetsインポートはこの経路でキャンペーン分のリンクを、リミッターに触れずに移している。

429 Too Many Requests(RFC 6585がまさにこのために予約したステータス)は、何秒待つべきかを伝える Retry-After ヘッダー付きで返る。これを尊重すること。この数字は、トークンがいつ利用可能になるかをリミッターが教えているもので、バックオフの推測よりも良い情報だ。

リトライ: どのコードをリトライし、どうバックオフするか

すべてのエラーがリトライに値するわけではなく、誤ったものをリトライすることで小さな障害が障害全体になる。レスポンスを2つの山に分類する。

リトライすべきもの(一時的なため): 429(速すぎた)、500、502、503、504(サーバー側またはゲートウェイの障害で自然に回復する可能性がある)。リトライしてはいけないもの(同じリクエストが同様に失敗するため): 400(ペイロードが無効)、401(トークンが欠落または誤り)、403(トークンのスコープが不足)、404(リソースが存在しないか自分のものではない)、409(スラッグの競合または古いバージョンの編集)。1つ目の山は「待って再試行」、2つ目の山は「コードまたは入力を修正」だ。タイトなループで 400 をリトライするのは、バグを自分自身へのサービス拒否攻撃に変えることに等しい。

リトライ可能なコードに対して重要なアルゴリズムは、ジッター付き指数バックオフだ。単純な指数バックオフ(試行のたびに待機時間を2倍にする)でも、同じ瞬間に失敗したクライアント全員が同じタイミングでリトライするため、クライアントが同期してしまう。ランダム性を加えることで分散させる。AWSの指数バックオフとジッターに関するブログ記事が標準的なリファレンスであり、ジッター付きバージョンが競合を劇的に削減する理由を示している。TypeScriptのコンパクトな実装:

const RETRYABLE = new Set([429, 500, 502, 503, 504]);

async function withRetry<T>(
  call: () => Promise<Response>,
  max = 5,
): Promise<Response> {
  let attempt = 0;
  while (true) {
    const res = await call();
    if (res.ok || !RETRYABLE.has(res.status) || attempt >= max) return res;

    // Honor server guidance first; otherwise back off exponentially with full jitter.
    const retryAfter = Number(res.headers.get("retry-after"));
    const base =
      Number.isFinite(retryAfter) && retryAfter > 0
        ? retryAfter * 1000
        : Math.min(1000 * 2 ** attempt, 20_000);
    const wait = Math.random() * base; // full jitter
    await new Promise((r) => setTimeout(r, wait));
    attempt++;
  }
}

これを危険ではなく安全にする3つの要素がある。試行回数に上限を設けているため、永続的な障害は無限ループするのではなく明確に失敗する。サーバーが Retry-After を送信した場合はそれを尊重し、送信しない場合のみ計算されたバックオフにフォールバックする。そしてジッターを使うため、同じ瞬間に問題から回復するワーカーの集団がロックステップで殺到しない。これまでに公開されているのはGo SDKだけで、TypeScript、Python、RubyのSDKはまだ各レジストリにない。そのため、これらの言語ではプレーンなHTTPの上に載せたこの小さなラッパーが必要なクライアントのすべてだ。

リトライを次のセクションに結びつけるルールが一つある。作成のリトライは、その作成が冪等である場合にのみ安全だ。そうでなければ、リトライのたびに2つ目のリンクが作られるリスクがある。

冪等性: 重複リンクを作らない方法

典型的な障害はこのような形だ。ワーカーが短縮リンクを作成し、リンクは作成されたが、200 が戻ってこない。戻りの途中で接続が切れた。ワーカーはタイムアウトを見て失敗と判断し、リトライする。これで1つのキャンペーンに2つのリンクができた。大規模になると、ダッシュボードが /foo、/foo-1、/foo-2 で埋まり、重複が下流のすべてのレポートを歪める。

冪等性キーはそのギャップを埋める。ミューテーションリクエストに Idempotency-Key ヘッダーを付与する(最大255文字の任意の文字列)と、サーバーはそれに対してレスポンスを保存する。同じキーを再度提示すると、操作が2回実行される代わりに元のレスポンス(ステータスコードとボディ)が返ってくる。このパターンはStripeが冪等なリクエストのために文書化しているものと同じで、信頼性の低いネットワークでの書き込みを安全にする標準的な方法だ。

成否を分ける詳細は、キーをどこから取得するかだ。試行ごとにランダムなキーを生成してはいけない。そうすると各リトライが新しい操作に見えてしまい、目的を果たせない。安定したビジネス識別子から導出して、同じ論理アクションが常に同じキーを持つようにする:

const res = await withRetry(() =>
  fetch(`https://api.elido.app/v1/workspaces/${WORKSPACE_ID}/links`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `order-${order.id}-link`,
    },
    body: JSON.stringify({ destination_url: order.landingUrl }),
  }),
);
const link = await res.json();

これで同じジョブのリトライが再び order-12345-link を持ち、保存されたレスポンスにヒットし、すでに存在するリンクを返す。キューが何回再配信しても、注文1つにつき正確に1つのリンクができる。これにより、上記のバックオフループと作成を安全に組み合わせることができる。リトライと冪等性キーは同じ保証の2つの半分だ。

at-least-onceジョブキューが同じ冪等性キーを持つ2つの作成リクエストを送信する。サーバーは最初のレスポンスを保存し、2回目にはそれを返すため、リンクは正確に1つだけ作成される

覚えておくべき2つの境界がある。キーはワークスペース単位でスコープされる。2つのワークスペースで同じキーを使うと2つのリンクが作成される。これはマルチテナントAPIとして正しい動作だが、キーがグローバルだと思っているチームには驚きになる。そしてキャッシュは永遠ではない。Elidoでは (workspace, key) をキーとして24時間保持される。ウィンドウ内のリトライは重複排除されるが、詰まっていたジョブがようやく排出されて3日後にリトライされると、新しいリンクが作成される。複数日にわたるバッチでは、キーだけに頼らない。最初の成功時に返されたリンクIDを永続化し、作成を再実行する前に確認する。IETFはこのヘッダーをIdempotency-Keyドラフトで標準化しており、24時間ウィンドウの注意事項もそこで言及されている。

今日APIの統合を組もうとしており、自分のリトライに耐えられるようにしたいなら、無料ワークスペースで始めて、サービスアカウントトークンを生成し、重複が出てから後付けするのではなく最初の作成から冪等性キーを付与しよう。

まとめると

本番環境レベルの作成呼び出しは、3つのメカニズムを積み重ねたものだ。クライアントのペースをプランの持続レートに合わせ、429 をめったに受け取らないようにする。一時的なコードのみをリトライし Retry-After を尊重するジッター付きバックオフで呼び出しをラップする。ビジネスIDから導出した冪等性キーを持ち歩き、リトライを安全にする。素の fetch なら、上のラッパーが最初の2つを担い、キーだけを自分で用意すればよい:

export async function shortenForOrder(order: Order) {
  const res = await withRetry(() =>
    fetch(`https://api.elido.app/v1/workspaces/${WORKSPACE_ID}/links`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `order-${order.id}-link`,
      },
      body: JSON.stringify({
        destination_url: order.landingUrl,
        tags: [`order:${order.id}`],
      }),
    }),
  );
  if (res.status === 429) {
    // withRetry gave up and we are still limited. Defer the job.
    throw new RetryableJobError(Number(res.headers.get("retry-after")) || 1);
  }
  if (!res.ok) throw new Error(`create failed: ${res.status}`); // non-retryable
  return res.json();
}

これは特殊な手法ではない。書き込み量の多いAPIが当然に持つべき規律を短縮リンクに適用したものだ。見返りは、負荷がかかっても正しく動作し、リンクインベントリを静かに壊さない統合だ。同じAPIの読み取り側(リミッターを叩かずにクリックデータを引き出す)については、トレードオフをクリックトラッキングのWebhooksとポーリングで扱っており、エンドポイントの全体像はAPIとSDKsページと開発者向けソリューションの概要で確認できる。

ブログ関連記事

よくある質問

URL短縮APIのレート制限はどうなっているか?

Elidoの制限はワークスペース単位でプランに依存し、トークンバケット方式だ。Freeは毎秒10リクエスト(バースト20)、Proは毎秒30(バースト60)、Businessは毎秒100(バースト200)。その上にAPIキーごとの別バケットがある。制限に達するとAPIは429を返し、Retry-Afterヘッダーと、どのバケットに当たったかを示すX-RateLimit-Scopeヘッダーが付くので、クライアントはどれだけ待てばよいか分かる。一括作成は1リクエストあたり最大100リンクまで受け付けるため、大きなジョブにはバルクエンドポイントが適している。

リトライすべきAPIエラーはどれか?

リトライすべきは一時的な障害のみ。429(レート制限)、500、502、503、504がそれに当たる。指数バックオフとジッターを使い、リトライ回数の上限を設ける。400、401、403、404、409は絶対にリトライしない。これらはバリデーション失敗、認証問題、競合であり、同じリクエストは再び失敗する。リトライには冪等性キーを組み合わせて、リトライした作成が重複リンクを生まないようにする。

冪等性キーとは何か、いつ必要か?

冪等性キーは、ミューテーションリクエストに付与する不透明な文字列で、サーバーがリトライを認識して二度実行する代わりに元の結果を返せるようにする。作成や更新がリトライされる可能性があるときは常に必要だ。ネットワークは障害を起こし、ジョブキューはat-least-onceで配信するため、それは常時発生する。注文IDのような安定したビジネス識別子からキーを導出すれば、同じ論理操作が常に同じキーを持つ。

短縮リンクの重複作成を防ぐにはどうすればよいか?

すべての作成リクエストに安定したビジネス識別子から導出した冪等性キーを付与する。リクエストがリトライされた場合、サーバーは新しいリンクを作成する代わりに最初に作成したリンクを返す。数日後にリトライが発生する可能性があるジョブ(冪等性キャッシュの有効期間を超える)では、最初の成功時に返されたリンクIDも保存し、作成を再実行する前に確認する。

冪等性キーはどのくらい有効か?

Elidoでは冪等性キャッシュはワークスペースとキーをまとめた組み合わせでキーイングされ、24時間有効だ。この期間内のリトライは保存されたレスポンスを返す。期間を過ぎたリトライは新しいリクエストとして扱われ、新しいリソースが作成される。複数日にわたるバッチジョブでは、キーだけに頼らず、最初の成功時に返されたリンクIDを永続化する。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

登録不要。無料登録すると月 50 件のリンクを作成できます。

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
url shortener api rate limits
api idempotency key
retry with exponential backoff
429 too many requests
link shortener api
idempotent requests

続きを読む