OpenAI API RateLimitError対策!フルスクラッチで実装するExponential Backoff
はじめに
こんにちは!
any株式会社でプロダクトチームに所属しているエンジニアの @fumiyan です!
この記事は、any Product Team Advent Calendar2024 最終日の25日目の記事になります。
クリスマスは、Raspberry Piを3台使ってk8sを構築して遊ぶ予定です😇
本記事では、TypeScriptを用いてExponential Backoff(指数バックオフ)をフルスクラッチで実装し、OpenAI APIのRateLimitError対策を行った内容を解説します。
実装 & 検証
実装や検証に入る前に、補足として Exponential Backoffについて簡単に解説します。Exponential Backoffとは、リトライ時の待機時間を指数関数的に増加させるアルゴリズムです。主にネットワークエラーや一時的な障害からの回復を待つ場合や、スロットリング(過剰なリクエストへの制限)に対応する際に利用されます。この手法を採用することで、短時間での連続リクエストによるシステムへの負荷を軽減し、問題解決のための十分な時間を確保することができます。
例えば、リトライを4回行う場合の流れは以下の通りです。最初の試行後、待機時間が指数的に増加し、4回目でも成功しなければリトライ回数を超えるためエラーとなります。
最初の試行:成功で終了、失敗で1秒待機
1回目のリトライ:成功で終了、失敗で2秒待機
2回目のリトライ:成功で終了、失敗で4秒待機
3回目のリトライ:成功で終了、失敗で8秒待機
4回目のリトライ:成功で終了、失敗でエラー
より詳しい解説をお求めの方は、GCPのRedisの章になるのですがドキュメントにExponential Backoffについての記載がありますので、ぜひご覧ください。
なお、今回対応するOpenAIのAPI制限について詳しく知りたい方は、事前に公式ドキュメントのRate limitsをご確認いただくことをおすすめします。
実装
それでは、実装したExponential Backoffを解説していきます。
まず、今回実装したコードの全体像を先に示します。
import { RateLimitError } from 'openai';
type RequestFunction<T> = () => Promise<T>;
type ExponentialBackoffOptions = {
maxRetry?: number;
baseDelayMs?: number;
maxDelayMs?: number;
};
const sleep = async (msec: number): Promise<void> => { await new Promise((resolve) => setTimeout(resolve, msec)); };
const exponentialBackoff = async <T>(request: RequestFunction<T>, options: ExponentialBackoffOptions): Promise<T> => {
const maxRetry = options?.maxRetry ?? 10;
const baseDelayMs = options?.baseDelayMs ?? 1;
const maxDelayMs = options?.maxDelayMs ?? Infinity;
let retriesRemaining = maxRetry;
while (true) {
try {
return await request();
} catch (error: unknown) {
if (error instanceof RateLimitError && error.status === 429 && retriesRemaining > 0) {
const jitter = 1 - Math.random() * 0.75;
const delayMs = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, maxRetry - retriesRemaining)) * jitter;
await sleep(delayMs);
retriesRemaining--;
continue;
}
throw error;
}
}
};
各ポイントについて順に解説していきます。
type RequestFunction<T> = () => Promise<T>;
type ExponentialBackoffOptions = {
maxRetry?: number;
baseDelayMs?: number;
maxDelayMs?: number;
};
const sleep = async (msec: number): Promise<void> => { await new Promise((resolve) => setTimeout(resolve, msec)); };
const exponentialBackoff = async <T>(request: RequestFunction<T>, options: ExponentialBackoffOptions): Promise<T> => {
...
};
RequestFunction
とExponentialBackoffOptions
は、それぞれリトライ対象の処理(OpenAI APIリクエスト)と、Exponential Backoffの設定値の型を表します。RequestFunction
の返り値の型にはジェネリック型を利用し、利用者が具体的な型を指定できるようにします。一方、sleep
はミリ秒単位で同期的に処理を一時停止させる関数です。
const maxRetry = options?.maxRetry ?? 10;
const baseDelayMs = options?.baseDelayMs ?? 1;
const maxDelayMs = options?.maxDelayMs ?? Infinity;
let retriesRemaining = maxRetry;
maxRetry
、baseDelayMs
、maxDelayMs
は、Exponential Backoffの設定値を表します。値を設定しない場合は、デフォルト値が入るようになってます。
- maxRetry:最大リトライ回数を指定します。例えば、5を指定すると、最大で5回リトライが実行されます。
- baseDelayMs:リトライ時の基準となる待機時間をミリ秒単位で指定します。1000ミリ秒を指定した場合、待機時間は単純なケースでは1000ミリ秒、2000ミリ秒、4000ミリ秒、8000ミリ秒、16000ミリ秒と指数的に増加します。
- maxDelayMs:1回の待機時間の上限をミリ秒単位で指定します。たとえば、60000ミリ秒を指定すると、それ以上の待機時間は発生しません。
retriesRemaining
にはmaxRetry
の値を代入し、リトライ回数を管理しています。
while (true) {
try {
return await request();
} catch (error: unknown) {
if (error instanceof RateLimitError && error.status === 429 && retriesRemaining > 0) {
...
retriesRemaining--;
continue;
}
throw error;
}
}
リトライ処理は、while
文を使ったループで実現しています。処理の実行結果としてエラーが発生した場合、エラーがRateLimitError
であり、ステータスコードが429、さらにretriesRemaining
が0より大きい場合には、一定時間待機した後、リトライ回数を1減らしてリトライします。それ以外の場合は、例外をスローする設計にしています。
const jitter = 1 - Math.random() * 0.75;
const delayMs = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, maxRetry - retriesRemaining)) * jitter;
await sleep(delayMs);
最後に、待機時間の実装の核となる部分について解説します。前の解説で「単純なケースでは〜」と述べましたが、単純なExponential Backoffを利用すると、高負荷なリクエストが集中した場合、リトライ間隔が揃ってしまい、結果として再びRateLimitError
が発生する可能性があります。この問題を回避するために、jitter
を導入します。
jitter
の役割は、リトライ間隔にランダム性を加えることで、リクエストのタイミングを分散させる点にあります。これにより、複数のクライアントが同じリトライ間隔でリクエストすることを防ぎ、負荷の集中を軽減する効果が期待できます。今回の実装では、jitterの値が [0.25, 1) の範囲となるため、この値を待機時間(Math.min(maxDelayMs, baseDelayMs * Math.pow(2, maxRetry - retriesRemaining))
)に乗算することでリトライのタイミングが分散される仕組みになっています。
また、jitter
に関する解説として、以下の記事は内容がよく整理されており、非常に分かりやすいのでおすすめです。手法についても詳しく解説されているため、要件に合った手法を選択する際の参考にしていただければと思います。
sleep
については、前の解説した関数を利用しています。
検証
コードの解説が終わったので、実際にこのコードを利用して、OpenAI APIのRateLimitErrorに対応できるか検証していきます。
console.log('Begin Exponential backoff');
console.log('------------------------');
while (true) {
try {
return await request();
} catch (error: unknown) {
if (error instanceof RateLimitError && error.status === 429 && retriesRemaining > 0) {
const jitter = 1 - Math.random() * 0.75;
const delayMs = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, maxRetry - retriesRemaining)) * jitter;
console.log(`${retriesRemaining}: ${delayMs / 1000}s`);
await sleep(delayMs);
retriesRemaining--;
continue;
}
console.log('------------------------');
console.log('End Exponential backoff');
throw error;
}
}
単純にconsole.log
を使用してログを出力する方法を採用しました。テストを行う際には、mock APIなどを利用して意図的に429エラーを発生させると効果的です。筆者はOpenAI APIに近い環境で検証を行いたかったため、Azure OpenAI Serviceを使用しました。このサービスでは、Rate Limitを自由に調整できるため、Rate Limitを極端に下げて効率的に検証を実施しました。
設定値
maxRetry: 5
baseDelayMs: 10000
maxDelayMs: 60000
検証結果が以下の条件を満たす場合、成功とみなします。
- リトライ処理が実行されている
- リトライ回数が
maxRetry
を超えていない - 待機時間が
maxDelayMs
を超えていない - 待機時間に一定のばらつきがある
検証結果
5 | 4 | 3 | 2 | 1 | |
---|---|---|---|---|---|
実験1 | 6.0878s | 10.4914s | 20.5397s | 45.6182s | 29.2343s |
実験2 | 9.4574s | 5.5723s | 26.8684s | 16.1126s | 38.8201s |
実験3 | 5.8278s | 13.9478s | 30.2622s | 29.4223s | 33.4215s |
実験4 | 9.4171s | 7.3043s | 39.4655s | 25.2773s | 21.0269s |
実験5 | 7.4813s | 12.3261s | 37.9764s | 17.3996s | 45.7057s |
実験6 | 3.2545s | 12.4790s | 32.9556s | 18.0909s | 30.6270s |
検証の結果、全ての条件を満たしたため、Exponential Backoffの実装は成功しました🎉🎉🎉
まとめ
以上、「OpenAI API RateLimitError対策!フルスクラッチで実装する Exponential Backoff」についてご紹介しました!
今回の実装は OpenAI API 以外にも応用可能ですので、ぜひ活用してみてください。
最後までお読みいただき、ありがとうございました!
来年はさらに進化した any の Advent Calendar をお届けできるよう、準備を進めてまいります。どうぞお楽しみに!
メリークリスマス🎅
Discussion