2023-12-16

stripe-node で "429 Too Many Requests" に対処する

この記事は JP_Stripes Advent Calendar 2023 の 16 日目の記事です。

Stripe API にはレートリミット制限があります。

レート制限 | Stripe のドキュメント

ほとんどの API について、Stripe は本番環境で 1 秒あたり最大 100 件の読み取り操作と 100 件の書き込み操作を許可し、テスト環境では 1 秒あたり 25 件の操作を許可します。

このレートリミット制限に達した場合、API から HTTP 429 ステータスコードが返却されます。

レートリミット制限に直面した場合、基本的には間隔をあけてリトライするのが基本になります。

組み込みが制限を適切に処理するための基本的な手法は、429 ステータスコードを監視し、再試行メカニズムを組み込むことです。再試行メカニズムは、必要に応じてリクエスト量を減らすために指数バックオフスケジュールに従う必要があります。また、Thundering Herd の影響を回避するために、バックオフスケジュールにランダム性を組み込むことをお勧めします。

https://stripe.com/docs/rate-limits#%E5%88%B6%E9%99%90%E3%81%AE%E9%81%A9%E5%88%87%E3%81%AA%E5%87%A6%E7%90%86

しかし、stripe-node の maxNetworkRetries オプションでは、ステータス 429 が返却されたときにリトライしてくれません。

const stripe = new Stripe(apikey, {
  apiVersion: '2020-08-27',
  maxNetworkRetries: 2,    // このように設定していても、ステータス429はリトライされない
}

この理由について、stripe-node の Issue では下記のように説明されています。

429 エラーに対して自動的にリトライしない理由は、そのエラーが発生するということは、あなたのアプリケーションに何らかの問題がある可能性が高いからです。通常のトラフィックで Stripe のレートリミットに到達するのは実際かなり難しいので、そのエラーが発生するということは、不必要なリクエストをしていないかアプリケーションを見直す必要があることを意味します。

レートリミットに達したことを、SDK 内で自動的にリトライすることで解決しようとするべきではありません。それは実際の根本的な問題を曖昧にするだけです。(自動翻訳)

https://github.com/stripe/stripe-node/issues/1110#issuecomment-764209269

しかし、「1秒あたり100件の操作」は実際わりと到達しうる気もしますし、また可能な限り Stripe へのリクエストを減らしたとしても、Web アプリケーションが突然バズったときに到達する可能性を拭うことはできません。

また、極力不整合を減らすためにも、決済系の操作については時間がかかってでも成功するまでリトライしてほしいというのもあります。

stripe-node の各メソッドを呼ぶ側で 429 をチェックしリトライする手もありますが、ここでは stripe-node のバックエンドである HttpClient を差し替えて、実現してみます。

export const stripe = new Stripe(env.stripeApiKey, {
  apiVersion: '2023-10-16',
  maxNetworkRetries: 2,
  httpClient: new StripeHttpClientWithRateLimitRetry(),
});

StripeHttpClientWithRateLimitRetry の実装は下記です。

class StripeHttpClientWithRateLimitRetry implements Stripe.HttpClient {
  baseHttpClient = Stripe.createFetchHttpClient();
  async makeRequest(
    host: string,
    port: string | number,
    path: string,
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    headers: Record<string, string>,
    requestData: string | null,
    protocol: Stripe.HttpProtocol,
    timeout: number,
  ): Promise<Stripe.HttpClientResponse> {
    console.log(headers);
    let resp = await this.baseHttpClient.makeRequest(
      host,
      port,
      path,
      method,
      headers,
      requestData,
      protocol,
      timeout,
    );

    headers = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
    for (let i = 0; resp.getStatusCode() === 429; i++) {
      console.log('Retrying');
      await new Promise(r => setTimeout(r, Math.min(2 << i, 20) * 100)); // 最大2秒待つ

      let modifiedHeaders = headers;
      if (headers['idempotency-key']) {
        modifiedHeaders = {
          ...headers,
          'idempotency-key': `${headers['idempotency-key']}-rate-limit-retry-${i}`,
        };
      }

      resp = await this.baseHttpClient.makeRequest(
        host,
        port,
        path,
        method,
        modifiedHeaders,
        requestData,
        protocol,
        timeout,
      );
    }

    return resp;
  }
  getClientName() {
    return this.baseHttpClient.getClientName();
  }
}

こんな感じの HttpClient に差し替えることで、各 API で 429 が返却されたときに自動でリトライすることが可能になります。