GoF以外のプログラミング・デザインパターン #17 Retry / Timeout / Bulkhead

2025/12/08

プログラミング 学習

t f B! P L
eyecatch デザインパターンの、Retry / Timeout / Bulkhead(リトライ・タイムアウト・バルクヘッド)は、 Circuit Breakerと並んでよく登場する「耐障害性パターン(Resilience Patterns)」の中核にある、デザインパターンです。 一時的なエラー(ネットワーク遅延・接続切れなど)の場合、すぐに失敗とせず、一定回数リトライして成功を待つのが特徴です。

サンプルコード1 : Retry(リトライ)パターン

一時的なエラー(ネットワーク遅延・接続切れなど)の場合、すぐに失敗とせず、一定回数リトライして成功を待つコードです。 async function retry(fn, retries = 3, delay = 1000) { for (let i = 0; i < retries; i++) { try { return await fn(); } catch (e) { console.log(`Retry ${i + 1} failed`); if (i === retries - 1) throw e; await new Promise(r => setTimeout(r, delay * (i + 1))); // backoff } } } async function unstableAPI() { if (Math.random() < 0.7) throw new Error("Temporary error"); return "Success!"; } retry(unstableAPI).then(console.log).catch(console.error);

ポイント

通常、指数バックオフ(1秒→2秒→4秒…)で待機時間を増やしています。 永久リトライはNGなので、上限回数や総時間を制御する必要がありますね。

サンプルコード2 : Retry(リトライ)パターン

外部APIや処理が一定時間内に応答しなければ中断するコードです。 「無限に待ち続ける」ことによるシステム全体の遅延・リソース枯渇を防ぐ。 function withTimeout(fn, ms) { return Promise.race([ fn(), new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms)) ]); } async function slowAPI() { await new Promise(r => setTimeout(r, 3000)); return "done"; } withTimeout(slowAPI, 2000) .then(console.log) .catch(e => console.error(e.message));

ポイント

Promise.race() で「処理 vs タイマー」を競わせるのが定番です。

サンプルコード3 : Bulkhead(バルクヘッド)パターン

システムの一部が障害を起こしても他の部分に波及しないよう隔離する設計パターンです。 このパターンの名前は、船の「防水隔壁(Bulkhead)」に由来しているそうです。 class Bulkhead { constructor(limit = 2) { this.limit = limit; this.active = 0; } async run(task) { if (this.active >= this.limit) { throw new Error("Bulkhead limit reached"); } this.active++; try { return await task(); } finally { this.active--; } } } const bulkhead = new Bulkhead(2); async function fakeAPI(id) { console.log("Start:", id); await new Promise(r => setTimeout(r, 1000)); console.log("Done:", id); } (async () => { for (let i = 1; i <= 4; i++) { bulkhead.run(() => fakeAPI(i)).catch(console.error); } })();

ポイント

このパターンの場合、通常は同時実行数を制限(スレッド数・コネクション数)して制御します。 「プール制御」「キュー制御」などが実装手段として使われます。

まとめ表

パターン名 目的 主な防御対象 代表的実装
Retry 一時的失敗からの再試行 一過性エラー 再試行+バックオフ
Timeout 処理の長時間停止を防ぐ 遅延/無限待機 Promise.raceなど
Bulkhead システムの独立性を確保 同時接続過多 スレッド・コネクション制限

ポイント

これら3つは、 Circuit Breakerと組み合わせると非常に強力なパターンになります。 (例:Retry → Timeout → Bulkhead → Circuit Breaker の多層防御構成) 参考ページ : GoF以外のプログラミング・デザインパターン #16 Circuit Breaker

可視化Canvasデモ

以下は 「Retry → Timeout → Bulkhead → Circuit Breaker」 の処理シーケンスを Canvasアニメで可視化する自己完結型HTMLです。 保存してブラウザで開けばそのまま動きます。 <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"/> <title>Resilience Flow: Retry → Timeout → Bulkhead → Circuit Breaker</title> <style> body { font-family: sans-serif; display:flex; flex-direction:column; align-items:center; padding:18px; background:#fafafa; } canvas { border:1px solid #ddd; background:white; } .controls { margin:10px 0; } label { margin-right:8px; font-size:14px; } input[type=range] { vertical-align:middle; } button { margin-left:8px; padding:6px 10px; } .legend { margin-top:8px; font-size:13px; color:#333; } </style> </head> <body> <h2>Resilience Flow Visualization</h2> <div class="controls"> <label>発行レート(ms): <input id="rate" type="range" min="200" max="1200" value="600"></label> <label>失敗率(%): <input id="fail" type="range" min="0" max="80" value="30"></label> <label>Timeout(ms): <input id="tout" type="range" min="200" max="2000" value="700"></label> <button id="toggle">Pause</button> <button id="reset">Reset CB</button> </div> <canvas id="c" width="900" height="340"></canvas> <div class="legend"> 青=進行中 / 緑=成功 / 赤=失敗・ドロップ / 橙=リトライ / 黄=CB半開 </div> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; const boxes = { retry: { x: 50, y: 80, w: 160, h: 160, label: 'Retry' }, timeout: { x: 260, y: 80, w: 160, h: 160, label: 'Timeout' }, bulk: { x: 470, y: 80, w: 160, h: 160, label: 'Bulkhead' }, cb: { x: 680, y: 80, w: 160, h: 160, label: 'Circuit Breaker' } }; let running = true; let emitRate = Number(document.getElementById('rate').value); let failRate = Number(document.getElementById('fail').value) / 100; let timeoutMs = Number(document.getElementById('tout').value); document.getElementById('rate').oninput = (e) => { emitRate = Number(e.target.value); }; document.getElementById('fail').oninput = (e) => { failRate = Number(e.target.value)/100; }; document.getElementById('tout').oninput = (e) => { timeoutMs = Number(e.target.value); }; document.getElementById('toggle').onclick = () => { running = !running; document.getElementById('toggle').textContent = running ? 'Pause' : 'Resume'; }; document.getElementById('reset').onclick = () => { cb.reset(); }; class Request { constructor(id) { this.id = id; this.x = boxes.retry.x + boxes.retry.w; this.y = boxes.retry.y + boxes.retry.h/2 + (Math.random()-0.5)*50; this.radius = 10; this.stage = 'retry'; // retry -> timeout -> bulk -> cb -> done this.color = '#3498db'; this.attempt = 0; this.startAt = performance.now(); this.sentAt = null; // when sent to external this.timedOut = false; } draw(ctx) { ctx.beginPath(); ctx.fillStyle = this.color; ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(this.attempt+1, this.x, this.y+3); } } const particles = []; let requestCounter = 0; /* --- Circuit Breaker --- */ const cb = { state: 'CLOSED', // CLOSED, OPEN, HALF-OPEN failureCount: 0, failureThreshold: 4, recoveryTime: 4000, nextTry: 0, halfOpenSuccesses: 0, halfOpenTrials: 0, reset() { this.state='CLOSED'; this.failureCount=0; this.nextTry=0; this.halfOpenSuccesses=0; this.halfOpenTrials=0; }, onSuccess() { if (this.state === 'HALF-OPEN') { this.halfOpenSuccesses++; this.halfOpenTrials++; if (this.halfOpenSuccesses >= 2) this.reset(); else if (this.halfOpenTrials >= 3 && this.halfOpenSuccesses < 2) { // stay half-open and let more trials happen } } else { this.failureCount = 0; } }, onFailure() { if (this.state === 'HALF-OPEN') { this.openNow(); } else { this.failureCount++; if (this.failureCount >= this.failureThreshold) this.openNow(); } }, openNow() { this.state = 'OPEN'; this.nextTry = performance.now() + this.recoveryTime; }, checkTransition() { if (this.state === 'OPEN' && performance.now() > this.nextTry) { this.state = 'HALF-OPEN'; this.halfOpenSuccesses = 0; this.halfOpenTrials = 0; } } }; /* --- Bulkhead (concurrency limiter) --- */ const bulk = { limit: 2, active: 0, queue: [] }; /* --- Retry config --- */ const retryCfg = { maxAttempts: 3, backoffBase: 200 }; /* --- simulate external call --- */ function externalCallSimulation(req) { // returns a Promise that resolves success/fail after variable delay return new Promise((resolve) => { req.sentAt = performance.now(); // processing time random 100..1200ms const proc = 100 + Math.random() * 1100; setTimeout(() => { // decide success on failRate unless CB is open (CB handled before calling) const isFailure = Math.random() < failRate; resolve({ success: !isFailure, took: proc }); }, proc); }); } /* --- processing pipeline --- */ function processArrivalToTimeout(req) { req.stage = 'timeout'; req.color = '#3498db'; // start a timeout watcher when sent to external // actual external call will be managed at bulk stage } function trySendToBulk(req) { req.stage = 'bulk'; // if bulkhead has space, consume slot and perform call; otherwise enqueue and possibly fail fast if (bulk.active < bulk.limit) { bulk.active++; doExternalWithTimeout(req).finally(() => { bulk.active--; // drain queue if (bulk.queue.length) { const next = bulk.queue.shift(); trySendToBulk(next); } }); } else { // enqueue, but if queue too long, drop immediately if (bulk.queue.length < 6) { bulk.queue.push(req); req.color = '#9b59b6'; // queued (purple) } else { // drop due to overload req.stage = 'done'; req.color = '#e74c3c'; // dropped } } } async function doExternalWithTimeout(req) { // Circuit Breaker: if OPEN, block immediately cb.checkTransition(); if (cb.state === 'OPEN') { req.stage = 'done'; req.color = '#e74c3c'; // blocked by CB return cb.onFailure(); } // if half-open, allow limited trials if (cb.state === 'HALF-OPEN') { cb.halfOpenTrials++; } // start external call with timeout req.sentAt = performance.now(); const race = Promise.race([ externalCallSimulation(req), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs)) ]); try { const res = await race; if (res && res.success) { // success req.color = '#2ecc71'; req.stage = 'cb'; cb.onSuccess(); } else { // external responded failure req.color = '#e74c3c'; cb.onFailure(); await handleRetryOrFail(req); } } catch (err) { // timeout or error req.timedOut = true; req.color = '#e74c3c'; cb.onFailure(); await handleRetryOrFail(req); } } async function handleRetryOrFail(req) { // decide retry if (req.attempt < retryCfg.maxAttempts - 1 && cb.state !== 'OPEN') { // schedule retry with backoff req.attempt++; req.color = '#f39c12'; // orange for retry const backoff = retryCfg.backoffBase * Math.pow(2, req.attempt-1); await new Promise(r => setTimeout(r, backoff)); // push back to timeout stage -> bulk send trySendToBulk(req); } else { // give up req.stage = 'done'; } } /* --- publisher --- */ let publisherTimer = null; function startPublisher() { if (publisherTimer) clearInterval(publisherTimer); publisherTimer = setInterval(() => { if (!running) return; const r = new Request(++requestCounter); r.attempt = 0; particles.push(r); }, emitRate); } startPublisher(); /* adjust publisher when rate changed */ setInterval(() => { // update emit according to slider if (publisherTimer) clearInterval(publisherTimer); if (running) publisherTimer = setInterval(() => { const r = new Request(++requestCounter); r.attempt = 0; particles.push(r); }, emitRate); }, 800); /* main update/draw loop */ let last = performance.now(); function update(dt) { cb.checkTransition(); // move particles and advance through stages for (let i = particles.length-1; i>=0; i--) { const p = particles[i]; p.age = (p.age || 0) + dt; if (p.stage === 'retry') { // move toward timeout box const tx = boxes.timeout.x - 20; p.x += (tx - p.x) * 0.02 * dt / 16; p.y += ((boxes.timeout.y + boxes.timeout.h/2) - p.y) * 0.001 * dt; if (p.x >= tx - 1) { // move to timeout handling and then send to bulk processArrivalToTimeout(p); trySendToBulk(p); } } else if (p.stage === 'bulk' || p.stage === 'queued') { // while waiting in queue, keep near bulk box const targetX = boxes.bulk.x + boxes.bulk.w/2; p.x += (targetX - p.x) * 0.03 * dt / 16; p.y += ((boxes.bulk.y + boxes.bulk.h/2) - p.y) * 0.03 * dt / 16; } else if (p.stage === 'cb') { // move to cb box const tx = boxes.cb.x - 20; p.x += (tx - p.x) * 0.02 * dt / 16; p.y += ((boxes.cb.y + boxes.cb.h/2) - p.y) * 0.002 * dt; if (p.x >= tx - 1) { // reached circuit box: mark done/success if (p.color === '#2ecc71') { // success path -> done p.stage = 'done'; } else { p.stage = 'done'; } } } // remove done old particles if (p.stage === 'done' && p.age > 1500) { particles.splice(i,1); } } // drain bulk queue if space while (bulk.active < bulk.limit && bulk.queue.length > 0) { const next = bulk.queue.shift(); trySendToBulk(next); } } function draw() { ctx.clearRect(0,0,W,H); // draw boxes Object.values(boxes).forEach(b => { ctx.fillStyle = '#f4f6f8'; ctx.fillRect(b.x, b.y, b.w, b.h); ctx.strokeStyle = '#ccc'; ctx.strokeRect(b.x, b.y, b.w, b.h); ctx.fillStyle = '#333'; ctx.font = '16px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(b.label, b.x + b.w/2, b.y + 24); }); // draw arrows drawArrow(boxes.retry.x + boxes.retry.w, boxes.retry.y + boxes.retry.h/2, boxes.timeout.x, boxes.timeout.y + boxes.timeout.h/2, '→'); drawArrow(boxes.timeout.x + boxes.timeout.w, boxes.timeout.y + boxes.timeout.h/2, boxes.bulk.x, boxes.bulk.y + boxes.bulk.h/2, '→'); drawArrow(boxes.bulk.x + boxes.bulk.w, boxes.bulk.y + boxes.bulk.h/2, boxes.cb.x, boxes.cb.y + boxes.cb.h/2, '→'); // annotate bulk state ctx.fillStyle = '#222'; ctx.font='13px sans-serif'; ctx.textAlign='left'; ctx.fillText(`active: ${bulk.active}/${bulk.limit} queue: ${bulk.queue.length}`, boxes.bulk.x, boxes.bulk.y + boxes.bulk.h + 18); // annotate cb state let cbColor = '#4CAF50'; if (cb.state === 'OPEN') cbColor = '#e74c3c'; if (cb.state === 'HALF-OPEN') cbColor = '#f1c40f'; ctx.fillStyle = cbColor; ctx.fillRect(boxes.cb.x + 10, boxes.cb.y + 36, boxes.cb.w - 20, 28); ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText(cb.state, boxes.cb.x + boxes.cb.w/2, boxes.cb.y + 56); // draw particles particles.forEach(p => p.draw(ctx)); // small HUD ctx.fillStyle = '#333'; ctx.font='12px monospace'; ctx.textAlign='left'; ctx.fillText(`Requests: ${requestCounter}`, 10, 18); ctx.fillText(`FailRate: ${Math.round(failRate*100)}% Timeout: ${timeoutMs}ms EmitRate: ${emitRate}ms`, 10, 36); ctx.fillText(`CB failures:${cb.failureCount} threshold:${cb.failureThreshold}`, 10, 54); } function drawArrow(x1,y1,x2,y2,label='') { ctx.strokeStyle = '#888'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); const angle = Math.atan2(y2-y1,x2-x1); const len = 8; ctx.beginPath(); ctx.moveTo(x2,y2); ctx.lineTo(x2 - len*Math.cos(angle - 0.3), y2 - len*Math.sin(angle - 0.3)); ctx.lineTo(x2 - len*Math.cos(angle + 0.3), y2 - len*Math.sin(angle + 0.3)); ctx.closePath(); ctx.fillStyle = '#888'; ctx.fill(); if (label) { ctx.fillStyle='#666'; ctx.font='12px sans-serif'; ctx.textAlign='center'; ctx.fillText(label,(x1+x2)/2,(y1+y2)/2 - 8); } } let lastTime = performance.now(); function loop(now) { const dt = now - lastTime; lastTime = now; update(dt); draw(); requestAnimationFrame(loop); } requestAnimationFrame(loop); /* publisher timer re-creation to reflect slider changes */ let pubInt = setInterval(() => { if (!running) return; const r = new Request(++requestCounter); r.attempt = 0; particles.push(r); }, emitRate); setInterval(() => { clearInterval(pubInt); if (running) pubInt = setInterval(() => { const r = new Request(++requestCounter); r.attempt = 0; particles.push(r); }, emitRate); }, 600); /* when a particle is created, it will be processed by bulk via trySendToBulk when reaches target. we need to attempt to send queued ones if bulk has space; that's handled in update loop above. */ /* also allow adjusting emitRate/failRate/timeout via controls already bound above */ </script> </body> </html>
青=進行中 / 緑=成功 / 赤=失敗・ドロップ / 橙=リトライ / 黄=CB半開
色の意味:青=進行中、緑=成功、赤=失敗/ドロップ、黄=Circuit半開(試験)/警告。

あとがき

失敗をうまく処理することは、上質なシステム設計につながります。 Retry / Timeout / Bulkhead は、それぞれ違うタイプの処理をしますが、目的は失敗回避です。 Javascriptでは他の言語と違って、特有の処理や命令パターンになりがちですが、本質を理解すれば、多言語でも対応は難しくないでしょう。

人気の投稿

このブログを検索

ごあいさつ

このWebサイトは、独自思考で我が道を行くユゲタの少し尖った思考のTechブログです。 毎日興味がどんどん切り替わるので、テーマはマルチになっています。 もしかしたらアイデアに困っている人の助けになるかもしれません。

ブログ アーカイブ