デザインパターンの、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半開(試験)/警告。
0 件のコメント:
コメントを投稿