0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでサーキットブレーカーを実装する:Closed・Open・Half-Openと同時プローブ制御

0
Posted at

TypeScriptでサーキットブレーカーを実装する:Closed・Open・Half-Openと同時プローブ制御

参考にした自社記事(内容は再構成しています):https://aceround.app/blog/sre-interview-ai

外部APIやデータベースが遅延・障害を起こしたとき、呼び出し側がリクエストを送り続けると、障害が連鎖して自分のサービスまで応答できなくなります。サーキットブレーカーはこの連鎖を、呼び出し先を一時的に「開放」することで止めるパターンです。

この記事では、Node.jsでそのまま実行できるTypeScript実装を作ります。ポイントは次の3つです。

  • 連続失敗が閾値に達したら Open に遷移し、呼び出しを即時拒否する
  • 冷却時間が過ぎたら、同時に1本だけ Half-Open のプローブを許可する
  • タイムアウト時には AbortSignal で下流処理にもキャンセルを伝える

状態遷移

Half-Open は「復旧したかもしれない」という仮説を検証する状態です。ここで全トラフィックを通すと、復旧途中の依存先に再び負荷をかけてしまいます。そのため、実装ではプローブを1本に制限します。

実装

type BreakerState = "closed" | "open" | "half-open";

type CircuitBreakerOptions = {
  failureThreshold: number;
  resetTimeoutMs: number;
  requestTimeoutMs: number;
};

export class CircuitOpenError extends Error {
  constructor() {
    super("circuit is open");
    this.name = "CircuitOpenError";
  }
}

export class CircuitBreaker<T> {
  private state: BreakerState = "closed";
  private consecutiveFailures = 0;
  private openedAt = 0;
  private probeInFlight = false;

  constructor(
    private readonly operation: (signal: AbortSignal) => Promise<T>,
    private readonly options: CircuitBreakerOptions,
  ) {}

  getState(): BreakerState {
    return this.state;
  }

  async execute(): Promise<T> {
    const probe = this.admit();
    const controller = new AbortController();
    let timeoutId: ReturnType<typeof setTimeout> | undefined;

    try {
      const timeout = new Promise<never>((_, reject) => {
        timeoutId = setTimeout(() => {
          controller.abort();
          reject(new Error("downstream request timed out"));
        }, this.options.requestTimeoutMs);
      });

      const result = await Promise.race([
        this.operation(controller.signal),
        timeout,
      ]);
      this.recordSuccess();
      return result;
    } catch (error) {
      this.recordFailure();
      throw error;
    } finally {
      if (timeoutId !== undefined) clearTimeout(timeoutId);
      if (probe) this.probeInFlight = false;
    }
  }

  private admit(): boolean {
    if (this.state === "open") {
      if (Date.now() - this.openedAt < this.options.resetTimeoutMs) {
        throw new CircuitOpenError();
      }
      this.state = "half-open";
    }

    if (this.state === "half-open") {
      if (this.probeInFlight) throw new CircuitOpenError();
      this.probeInFlight = true;
      return true;
    }
    return false;
  }

  private recordSuccess(): void {
    this.consecutiveFailures = 0;
    this.state = "closed";
  }

  private recordFailure(): void {
    if (this.state === "half-open") {
      this.state = "open";
      this.openedAt = Date.now();
      return;
    }

    this.consecutiveFailures += 1;
    if (this.consecutiveFailures >= this.options.failureThreshold) {
      this.state = "open";
      this.openedAt = Date.now();
    }
  }
}

なぜ probeInFlight が必要か

Open の冷却時間が過ぎた直後は、複数のHTTPリクエストが同じイベントループのターンで execute() に到達する可能性があります。最初の呼び出しが Half-Open にした直後にフラグを立てることで、2本目以降は下流へ到達せずに拒否されます。

実行可能な境界テスト

次のテストは外部ライブラリを使わず、bun run だけで実行できます。実際の面接では、状態遷移を図にしてから「失敗を何回数えるか」「復旧判定の負荷をどう制限するか」を説明すると、実装の意図が伝わりやすくなります。

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assert = (condition: unknown, message: string) => {
  if (!condition) throw new Error(message);
};

let attempts = 0;
let shouldFail = true;
const breaker = new CircuitBreaker(
  async (signal) => {
    attempts += 1;
    if (signal.aborted) throw new Error("aborted");
    if (shouldFail) throw new Error("downstream unavailable");
    return "ok";
  },
  { failureThreshold: 2, resetTimeoutMs: 20, requestTimeoutMs: 50 },
);

await Promise.allSettled([breaker.execute(), breaker.execute()]);
assert(breaker.getState() === "open", "2回の失敗でOpenになる");
const attemptsWhileOpen = attempts;
await Promise.allSettled([breaker.execute()]);
assert(attempts === attemptsWhileOpen, "Open中は下流を呼ばない");

shouldFail = false;
await sleep(25);
assert((await breaker.execute()) === "ok", "Half-Openのプローブが成功する");
assert(breaker.getState() === "closed", "成功後はClosedに戻る");

let releaseProbe!: () => void;
const probeStarted = new Promise<void>((resolve) => {
  releaseProbe = resolve;
});
let probeCalls = 0;
const probeBreaker = new CircuitBreaker(
  async () => {
    probeCalls += 1;
    if (probeCalls === 1) throw new Error("seed failure");
    await probeStarted;
    return "probe-ok";
  },
  { failureThreshold: 1, resetTimeoutMs: 1, requestTimeoutMs: 100 },
);
await Promise.allSettled([probeBreaker.execute()]);
await sleep(2);
const firstProbe = probeBreaker.execute();
await sleep(1);
const secondProbe = await Promise.allSettled([probeBreaker.execute()]);
assert(
  secondProbe[0]?.status === "rejected" &&
    secondProbe[0].reason instanceof CircuitOpenError,
  "Half-Openではプローブを同時に2本通さない",
);
releaseProbe();
assert((await firstProbe) === "probe-ok", "プローブ完了後に結果を返す");

let aborted = false;
const timeoutBreaker = new CircuitBreaker(
  (signal) =>
    new Promise<never>((_, reject) => {
      signal.addEventListener("abort", () => {
        aborted = true;
        reject(new Error("aborted"));
      });
    }),
  { failureThreshold: 1, resetTimeoutMs: 20, requestTimeoutMs: 5 },
);
await Promise.allSettled([timeoutBreaker.execute()]);
assert(aborted, "タイムアウト時に下流のAbortSignalを発火する");

実運用で追加する制約

この最小実装は、1プロセス内の状態だけを扱います。複数インスタンスで同じ下流を守る場合、各インスタンスが独立してプローブを送るため、共有ストアやサービスメッシュ側のサーキットブレーカーが必要です。

また、失敗をすべて同じ重みで数えるのも危険です。入力エラー(HTTP 400)を障害として扱うと、正常なクライアントのミスで回路が開きます。通常はタイムアウト、接続エラー、HTTP 5xx など、再試行しても改善しない失敗だけを対象にします。

最後に、回路が開いた回数、プローブの成功率、拒否したリクエスト数をメトリクスに出してください。サーキットブレーカーは障害を隠す機能ではなく、障害の影響範囲を小さくし、復旧の観測点を増やすための機能です。

まとめ

  • Closed では失敗を数え、閾値を超えたら Open にする
  • Open では即時拒否し、冷却時間後に1本だけ Half-Open のプローブを送る
  • タイムアウトは AbortSignal で下流へ伝播し、入力エラーは失敗数から除外する
  • 複数インスタンスでは状態共有とメトリクスが別途必要

この4点をコードとテストで説明できれば、サーキットブレーカーを「用語として知っている」状態から「障害境界を設計できる」状態へ進めます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?