好きなときにエラーを返すモック API

テストが必要とするときにいつでも、500、Retry-After 付きの 429、タイムアウト、途中で切れたボディを受け取れます。

例を API に対して最後に実行した日:

失敗する API をコードがどう扱うかをテストするなら、わざと失敗するエンドポイントに向けましょう。https://api.testingapis.com は無料で、キーも不要、どのオリジンからのリクエストも受け付けます。/api/status/500 は 500 を、/api/status/429 は Retry-After 付きの 429 を返します。200 から 599 までどのコードでも使えます。実在のエンドポイントに ?error=503 を付けるとそれが失敗し、/api/chaos/retry/{key}サインインして表示 は決まった回数だけ失敗してから成功します。/api/chaos/timeoutサインインして表示 はクライアントがあきらめるまで応答せず、/api/chaos/partialサインインして表示 はレスポンスを半分だけ送ります。それぞれの エンドポイントと、返ってくるレスポンスを紹介します。

どの失敗にどのエンドポイントを使うか

テストしたいものリクエスト返ってくるもの
特定のステータスコードGET /api/status/503そのステータス、JSON ボディ、そのステータスに通常付くヘッダー
失敗する実在のエンドポイントGET /api/Products/1?error=500指定したステータスと X-Simulated: true
遅い実在のエンドポイントGET /api/Products/1?delay=3N 秒後に通常のレスポンス(最大 10)
リトライのロジックGET /api/chaos/retry/my-key?succeedAfter=2サインインして表示2 回失敗した後に 200。X-Attempt が試行回数を数える
ランダムな失敗GET /api/chaos/flaky?failRate=0.3サインインして表示約 30% の確率で Retry-After: 1 付きの 503
クライアントのタイムアウトGET /api/chaos/timeout?seconds=30サインインして表示時間が来るまで何も返らない(1〜60 秒)
途中で切れるボディGET /api/chaos/partialサインインして表示ヘッダーは 10,000 バイトを約束するが、約 64 バイト届いた後に接続が閉じる
本物のレート制限GET /api/RateLimitサインインして表示60 秒で 5 リクエストを超えると Retry-After 付きの 429
重み付きの組み合わせGET /api/status/random?codes=200:3,500:1200 が 500 の 3 倍の頻度で返る

任意の HTTP ステータスコードを返す

/api/status/{code} はどのメソッドでも受け付け、パスのコードで応答します。そのステータスでクライアントが 期待するヘッダーも送ります。429 と 503 には Retry-After、401 には WWW-Authenticate、リダイレクトには Location、405 には Allow です。コードが数字だけを見ず、ヘッダーもきちんと読んでいるかを確かめられます。

Terminal
$ curl -i https://api.testingapis.com/api/status/429
HTTP/1.1 429 Too Many Requests
content-type: application/json; charset=utf-8
retry-after: 5

{"status":429,"statusText":"Too Many Requests","category":"client-error","message":"This is a 429 response. Use /api/status/{code} to request any status."}
GET/api/status/429

実在のエンドポイントを失敗させる

/api/status/500 だけを呼ぶテストでは、商品を読み込む画面について多くは証明できません。アプリがすでに送っている リクエストに error を付ければ、その呼び出しはデータを返さずに失敗します。?delay=N は先に N 秒待つので、?delay=3&error=503 を使えば、読み込み中の表示が出てからエラーメッセージに切り替わることを確かめられます。

Terminal
$ curl -i "https://api.testingapis.com/api/Products/1?error=503"
HTTP/1.1 503 Service Unavailable
x-simulated: true

{"status":503,"error":"Service Unavailable","message":"Simulated error. Use ?error={code} to test different status codes.","simulated":true}
GET/api/Products/1?error=503

どちらのパラメータも、書き込みを含むすべてのエンドポイントで使えます。全一覧はAPI ドキュメントにあります。

推測に頼らずリトライをテストする

ランダムな失敗はリトライのテストを不安定にします。/api/chaos/retry/{key}サインインして表示 は決定的です。succeedAfter=2 なら、あるキーへの最初の 2 回は失敗し、3 回目で成功します。failStatus で失敗時のステータス(429、500、502、503、504)を選べ、すべてのレスポンスに X-Attempt ヘッダーが付くので、クライアントが何回試行したかをテストで確認できます。テストの実行ごとに新しいキーを使うか、DELETE でリセットしてください。

Terminal
$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=429"
HTTP/1.1 429 Too Many Requests
retry-after: 1
x-attempt: 1

$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=429"
HTTP/1.1 429 Too Many Requests
retry-after: 1
x-attempt: 2

$ curl -i "https://api.testingapis.com/api/chaos/retry/my-test?succeedAfter=2&failStatus=429"
HTTP/1.1 200 OK
x-attempt: 3

$ curl -X DELETE https://api.testingapis.com/api/chaos/retry/my-test
{"message":"Counter for 'my-test' reset after 3 attempt(s).","key":"my-test"}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

Node の組み込みテストランナーを使った、リトライするクライアントのテストです。指数バックオフと Retry-After に対応した完全な fetchWithRetry は、タイムアウト、リトライ、レート制限のレッスン(英語)にあります。

JavaScript
import { test } from "node:test";
import assert from "node:assert/strict";
import { fetchWithRetry } from "./client.js"; // your code under test

test("retries a 503 and returns the eventual 200", async () => {
  const key = `ci-${Date.now()}`;
  const res = await fetchWithRetry(
    `https://api.testingapis.com/api/chaos/retry/${key}?succeedAfter=2`,
  );
  assert.equal(res.status, 200);
  assert.equal(res.headers.get("x-attempt"), "3");
});

test("gives up on a 400 without retrying", async () => {
  const res = await fetchWithRetry("https://api.testingapis.com/api/status/400");
  assert.equal(res.status, 400);
});
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

タイムアウトと切れた接続

ステータスコードを返さない失敗もあります。クライアントが自分で気づく必要があり、res.ok しか見ないスクリプトには見えません。このスクリプトは失敗する 5 つのエンドポイントを呼び、fetch がそれぞれでどう動くかを表示します。

failures.mjs
const BASE = "https://api.testingapis.com";

async function attempt(label, url, init) {
  try {
    const res = await fetch(url, init);
    const body = await res.text();
    console.log(label.padEnd(8), res.status, res.headers.get("retry-after") ?? "-", body.slice(0, 50));
  } catch (err) {
    console.log(label.padEnd(8), "threw", err.name, err.cause?.code ?? err.message);
  }
}

await attempt("500", `${BASE}/api/status/500`);
await attempt("429", `${BASE}/api/status/429`);
await attempt("error=", `${BASE}/api/Products/1?error=503`);
await attempt("timeout", `${BASE}/api/chaos/timeout?seconds=10`, { signal: AbortSignal.timeout(2000) });
await attempt("partial", `${BASE}/api/chaos/partial`);
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
出力(Node 24)
500      500 - {"status":500,"statusText":"Internal Server Error
429      429 5 {"status":429,"statusText":"Too Many Requests","c
error=   503 - {"status":503,"error":"Service Unavailable","mes
timeout  threw TimeoutError The operation was aborted due to timeout
partial  threw TypeError UND_ERR_SOCKET

タイムアウトと途中で切れたボディは、レスポンスを返さずに例外を投げます。タイムアウトの例外は、AbortSignal.timeout(2000) が 2 秒であきらめたためです。signal がなければ、fetch は 10 秒まるごと待って いたでしょう。部分的なレスポンスは、ステータスとヘッダーが正常に見える形で届いた後、ボディを読む途中で例外を投げます。 curl も同じ問題を報告します。

Terminal
$ curl -sS -o /dev/null https://api.testingapis.com/api/chaos/partial
curl: (18) end of response with 9936 bytes missing
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

本物のレートリミッター

/api/status/429 は 1 回のレスポンスを装うだけです。/api/RateLimitサインインして表示 は本物の固定ウィンドウ方式のリミッターで、クライアントごとに 60 秒あたり 5 リクエストまで。X-RateLimit-Remaining が減っていき、6 回目で正確な Retry-After 付きの 429 が返ります。X-Client-Id ヘッダーを送ればテストごとに専用のウィンドウになり、POST /api/RateLimit/resetサインインして表示 でリセットできます。すべてのヘッダーはレート制限のリファレンスにあります。

GET/api/RateLimit
このリクエストはサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

クライアントで確認すること

  • 408 と 429 以外の 4xx エラーはリトライしない。悪いリクエストは何度送っても悪いまま。
  • 429 と 503 はすぐにリトライせず、Retry-After を待つ。
  • リトライは最大回数で止まり、最後のエラーがユーザーに届く。
  • すべてのリクエストにタイムアウトがあり、タイムアウトしたら終わらないスピナーではなくエラーを表示する。
  • 解析できない、または不完全なボディは、ステータスが 200 でもエラーとして扱う。
  • エラーボディの message は握りつぶさず、表示するかログに残す。