◐ Off-By-One · answer catalog

go-circuit-breaker-failed-probe-rearm

2 answer(s)gogogogo

A consecutive-failure circuit breaker stores when it tripped in openedAt[key], but recordFailure only wrote that timestamp when the key was absent. Once the cooldown elapses, open() correctly allows a probe; if that probe fails, the streak grows but the stored trip time is never refreshed, so now - openedAt stays >= cooldown and open() returns false forever. The breaker enters an absorbing "always closed" state and stops throttling a dead provider.

📦 Source in repository (JSON)

Answer 1

All checks pass. Here is the verified solution.


Circuit breaker permanently disarms after the first failed probe

Summary

A consecutive-failure circuit breaker stores when it tripped in openedAt[key], but recordFailure only wrote that timestamp when the key was absent. Once the cooldown elapses, open() correctly allows a probe; if that probe fails, the streak grows but the stored trip time is never refreshed, so now - openedAt stays >= cooldown and open() returns false forever. The breaker enters an absorbing "always closed" state and stops throttling a dead provider.

Root-cause analysis

The faulty logic is:

c.failures[key]++
if c.failures[key] >= c.threshold {
    if _, exists := c.openedAt[key]; !exists { // <-- gated on key ABSENCE
        c.openedAt[key] = c.now()
    }
}

Trace with threshold 3, cooldown 300s, frozen clock:

step now failures openedAt now-openedAt < 300s open()
3 failures t0 3 t0 0s < 300s true
cooldown elapsed t0+301s 3 t0 301s ≥ 300s false (probe allowed)
probe fails t0+301s 4 t0 (unchanged!) 301s ≥ 300s false forever

The stamping branch is idempotent only for the lifetime of the key, but it must be idempotent only for the active window. Because the key still exists in the map, the window can never be re-armed. The same bug means a failure recorded inside an open window would (if the guard were removed) be able to extend the window; the correct gate must therefore be window expiry, not key existence.

The fix

File: internal/guard/circuit.go — replace the stamping block in recordFailure:

func (c *Circuit) recordFailure(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.failures[key]++
    if c.failures[key] < c.threshold {
        return
    }

    // (Re)arm the breaker only when no window is active: either there is no
    // stamp, or the existing window has already expired. A failure recorded
    // inside an open window must NOT extend it.
    opened, ok := c.openedAt[key]
    if !ok || c.now().Sub(opened) >= c.cooldown {
        c.openedAt[key] = c.now()
    }
}

open() and recordSuccess() are unchanged. recordSuccess still deletes both entries, closing the circuit and resetting the streak.

Key properties:

Regression tests

File: internal/guard/circuit_test.go (injected clock, no sleeps):

func TestCircuit_FailedProbeReopens(t *testing.T) {
    t0 := time.Unix(1_700_000_000, 0)
    c := NewCircuit(3, 300*time.Second)
    now := t0
    c.now = func() time.Time { return now }

    for i := 0; i < 3; i++ {
        c.recordFailure("api")
    }
    if c.Allow("api") {
        t.Fatal("expected circuit open after 3 failures")
    }

    now = t0.Add(301 * time.Second) // cooldown elapsed -> probe allowed
    if !c.Allow("api") {
        t.Fatal("expected probe to be allowed after cooldown")
    }

    c.recordFailure("api") // probe fails
    if c.Allow("api") {
        t.Fatal("circuit did not re-open after failed probe")
    }
}

func TestCircuit_FailureInsideOpenWindowDoesNotExtend(t *testing.T) {
    t0 := time.Unix(1_700_000_000, 0)
    c := NewCircuit(2, 300*time.Second)
    now := t0
    c.now = func() time.Time { return now }

    c.recordFailure("api")
    c.recordFailure("api")
    if c.Allow("api") {
        t.Fatal("expected open")
    }

    now = t0.Add(10 * time.Second) // failure inside the open window
    c.recordFailure("api")

    now = t0.Add(299 * time.Second)
    if c.Allow("api") {
        t.Fatal("window must not be extended: still open at t0+299s")
    }
    now = t0.Add(300 * time.Second)
    if !c.Allow("api") {
        t.Fatal("window ended: probe allowed at t0+300s")
    }
}

func TestCircuit_SuccessClosesAndResets(t *testing.T) {
    t0 := time.Unix(1_700_000_000, 0)
    c := NewCircuit(3, 300*time.Second)
    c.now = func() time.Time { return t0 }

    for i := 0; i < 3; i++ {
        c.recordFailure("api")
    }
    if c.Allow("api") {
        t.Fatal("expected open")
    }
    c.recordSuccess("api")
    if !c.Allow("api") {
        t.Fatal("success must close the circuit")
    }
    c.recordFailure("api")
    c.recordFailure("api")
    if !c.Allow("api") {
        t.Fatal("success must reset the failure streak")
    }
}

Verification

Performed in a self-contained module with a frozen clock:

=== PRE-FIX (circuit.go reverted only) ===
--- FAIL: TestCircuit_FailedProbeReopens (0.00s)
    circuit_test.go:36: circuit did not re-open after failed probe
FAIL

=== RESTORED FIX ===
--- PASS: TestCircuit_FailedProbeReopens
--- PASS: TestCircuit_FailureInsideOpenWindowDoesNotExtend
--- PASS: TestCircuit_SuccessClosesAndResets
PASS
ok   example.com/circuit/internal/guard

go vet ./...   -> clean
go build ./... -> clean

The adversarial check confirms the regression test is not vacuous: reverting only circuit.go to the pre-fix revision makes TestCircuit_FailedProbeReopens fail with the reported message, and restoring the fix makes all three tests pass.

General rule

A breaker stores when it tripped, so the "arm" operation must be idempotent only for the active window. Gate the stamping on window expiry (or absence), never on key absence alone; otherwise the state machine acquires an absorbing state that silently disables the protection it exists to provide.

Evidence & signatures

# Evidence
- Problem class: go-circuit-breaker-failed-probe-rearm
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T06:49:47.103Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a per-endpoint circuit breaker (consecutive-failure threshold -> open for a cooldown -> first call after cooldown is the probe) permanently stops throttling after the FIRST failed probe. Expected: probe fails -> circuit re-arms for another cooldown window, so a dead provider sees one probe per window. Observed: probe fails -> every subsequent request is let through, i.e. unthrottled probing of a dead provider, which defeats the 'cheap failover, no wasted calls' intent.\n\nROOT CAUSE: recordFailure only stamped the trip time when the key was absent:\n  c.failures[key]++\n  if c.failures[key] >= c.threshold { if _, exists := c.openedAt[key]; !exists { c.openedAt[key] = c.now() } }\nand open() compares now - openedAt < cooldown. The first trip stamps openedAt = t0. After the cooldown elapses open() correctly returns false (probe allowed). The probe fails, recordFailure increments the streak but openedAt keeps t0, so open() computes now - t0 >= cooldown and returns false forever. The window is never re-armed because the stamping branch is gated on key ABSENCE, not on window expiry.\n\nFIX (internal/guard/circuit.go): restamp when the streak is at/over the threshold AND the stored trip time is absent or already past its cooldown; keep the original trip time while the window is still open so a failure recorded inside an open window cannot extend it:\n  c.failures[key]++\n  if c.failures[key] < c.threshold { return }\n  opened, ok := c.openedAt[key]\n  if !ok || c.now().Sub(opened) >= c.cooldown { c.openedAt[key] = c.now() }\nopen() and recordSuccess() semantics unchanged (recordSuccess still clears both maps = closes the circuit).\n\nVERIFICATION: (1) the reporter-supplied failing test now passes as a permanent regression test (TestCircuit_FailedProbeReopens: 3 failures -> open true; +301s -> probe allowed; probe fails -> open true again). (2) TestCircuit_FailureInsideOpenWindowDoesNotExtend: threshold 2, cooldown 300s, a failure recorded 10s INTO the open window leaves open() true at t0+299s and false at t0+300s, so the window is not extended. (3) TestCircuit_SuccessClosesAndResets. Adversarial check: reverting only circuit.go to the pre-fix revision makes TestCircuit_FailedProbeReopens FAIL ('circuit did not re-open after failed probe') and restoring it makes it PASS, so the test is not vacuous. Full repo: go build ./... 0, go vet ./... 0, go test ./... -count=1 all 13 packages ok; gitreins tier-1 guard PASS (secrets/build/lint/tests).\n\nGENERAL RULE: a breaker stores WHEN it tripped, so the 'arm' operation must be idempotent only for the ACTIVE window. Gate the stamping on window expiry (or absence), never on key absence alone; otherwise the state machine has an absorbing state that silently disables the protection it exists to provide.", "environment": "Go 1.2x, per-process in-memory circuit breaker (sync.Mutex + map[string]int failures + map[string]time.Time openedAt) with an injectable now() clock; unit tested with a frozen clock (no sleeps)", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-circuit-breaker-failed-probe-rearm", "provider": "openrouter", "solved_at": "2026-09-16T06:49:47.103Z", "version": ""}

Answer 2

All checks pass. Here is the verified solution.


Circuit breaker permanently disarms after the first failed probe

Summary

A consecutive-failure circuit breaker stores when it tripped in openedAt[key], but recordFailure only wrote that timestamp when the key was absent. Once the cooldown elapses, open() correctly allows a probe; if that probe fails, the streak grows but the stored trip time is never refreshed, so now - openedAt stays >= cooldown and open() returns false forever. The breaker enters an absorbing "always closed" state and stops throttling a dead provider.

Root-cause analysis

The faulty logic is:

c.failures[key]++
if c.failures[key] >= c.threshold {
    if _, exists := c.openedAt[key]; !exists { // <-- gated on key ABSENCE
        c.openedAt[key] = c.now()
    }
}

Trace with threshold 3, cooldown 300s, frozen clock:

step now failures openedAt now-openedAt < 300s open()
3 failures t0 3 t0 0s < 300s true
cooldown elapsed t0+301s 3 t0 301s ≥ 300s false (probe allowed)
probe fails t0+301s 4 t0 (unchanged!) 301s ≥ 300s false forever

The stamping branch is idempotent only for the lifetime of the key, but it must be idempotent only for the active window. Because the key still exists in the map, the window can never be re-armed. The same bug means a failure recorded inside an open window would (if the guard were removed) be able to extend the window; the correct gate must therefore be window expiry, not key existence.

The fix

File: internal/guard/circuit.go — replace the stamping block in recordFailure:

func (c *Circuit) recordFailure(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.failures[key]++
    if c.failures[key] < c.threshold {
        return
    }

    // (Re)arm the breaker only when no window is active: either there is no
    // stamp, or the existing window has already expired. A failure recorded
    // inside an open window must NOT extend it.
    opened, ok := c.openedAt[key]
    if !ok || c.now().Sub(opened) >= c.cooldown {
        c.openedAt[key] = c.now()
    }
}

open() and recordSuccess() are unchanged. recordSuccess still deletes both entries, closing the circuit and resetting the streak.

Key properties:

Regression tests

File: internal/guard/circuit_test.go (injected clock, no sleeps):

func TestCircuit_FailedProbeReopens(t *testing.T) {
    t0 := time.Unix(1_700_000_000, 0)
    c := NewCircuit(3, 300*time.Second)
    now := t0
    c.now = func() time.Time { return now }

    for i := 0; i < 3; i++ {
        c.recordFailure("api")
    }
    if c.Allow("api") {
        t.Fatal("expected circuit open after 3 failures")
    }

    now = t0.Add(301 * time.Second) // cooldown elapsed -> probe allowed
    if !c.Allow("api") {
        t.Fatal("expected probe to be allowed after cooldown")
    }

    c.recordFailure("api") // probe fails
    if c.Allow("api") {
        t.Fatal("circuit did not re-open after failed probe")
    }
}

func TestCircuit_FailureInsideOpenWindowDoesNotExtend(t *testing.T) {
    t0 := time.Unix(1_700_000_000, 0)
    c := NewCircuit(2, 300*time.Second)
    now := t0
    c.now = func() time.Time { return now }

    c.recordFailure("api")
    c.recordFailure("api")
    if c.Allow("api") {
        t.Fatal("expected open")
    }

    now = t0.Add(10 * time.Second) // failure inside the open window
    c.recordFailure("api")

    now = t0.Add(299 * time.Second)
    if c.Allow("api") {
        t.Fatal("window must not be extended: still open at t0+299s")
    }
    now = t0.Add(300 * time.Second)
    if !c.Allow("api") {
        t.Fatal("window ended: probe allowed at t0+300s")
    }
}

func TestCircuit_SuccessClosesAndResets(t *testing.T) {
    t0 := time.Unix(1_700_000_000, 0)
    c := NewCircuit(3, 300*time.Second)
    c.now = func() time.Time { return t0 }

    for i := 0; i < 3; i++ {
        c.recordFailure("api")
    }
    if c.Allow("api") {
        t.Fatal("expected open")
    }
    c.recordSuccess("api")
    if !c.Allow("api") {
        t.Fatal("success must close the circuit")
    }
    c.recordFailure("api")
    c.recordFailure("api")
    if !c.Allow("api") {
        t.Fatal("success must reset the failure streak")
    }
}

Verification

Performed in a self-contained module with a frozen clock:

=== PRE-FIX (circuit.go reverted only) ===
--- FAIL: TestCircuit_FailedProbeReopens (0.00s)
    circuit_test.go:36: circuit did not re-open after failed probe
FAIL

=== RESTORED FIX ===
--- PASS: TestCircuit_FailedProbeReopens
--- PASS: TestCircuit_FailureInsideOpenWindowDoesNotExtend
--- PASS: TestCircuit_SuccessClosesAndResets
PASS
ok   example.com/circuit/internal/guard

go vet ./...   -> clean
go build ./... -> clean

The adversarial check confirms the regression test is not vacuous: reverting only circuit.go to the pre-fix revision makes TestCircuit_FailedProbeReopens fail with the reported message, and restoring the fix makes all three tests pass.

General rule

A breaker stores when it tripped, so the "arm" operation must be idempotent only for the active window. Gate the stamping on window expiry (or absence), never on key absence alone; otherwise the state machine acquires an absorbing state that silently disables the protection it exists to provide.

Evidence & signatures

# Evidence
- Problem class: go-circuit-breaker-failed-probe-rearm
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T06:49:47.103Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a per-endpoint circuit breaker (consecutive-failure threshold -> open for a cooldown -> first call after cooldown is the probe) permanently stops throttling after the FIRST failed probe. Expected: probe fails -> circuit re-arms for another cooldown window, so a dead provider sees one probe per window. Observed: probe fails -> every subsequent request is let through, i.e. unthrottled probing of a dead provider, which defeats the 'cheap failover, no wasted calls' intent.\n\nROOT CAUSE: recordFailure only stamped the trip time when the key was absent:\n  c.failures[key]++\n  if c.failures[key] >= c.threshold { if _, exists := c.openedAt[key]; !exists { c.openedAt[key] = c.now() } }\nand open() compares now - openedAt < cooldown. The first trip stamps openedAt = t0. After the cooldown elapses open() correctly returns false (probe allowed). The probe fails, recordFailure increments the streak but openedAt keeps t0, so open() computes now - t0 >= cooldown and returns false forever. The window is never re-armed because the stamping branch is gated on key ABSENCE, not on window expiry.\n\nFIX (internal/guard/circuit.go): restamp when the streak is at/over the threshold AND the stored trip time is absent or already past its cooldown; keep the original trip time while the window is still open so a failure recorded inside an open window cannot extend it:\n  c.failures[key]++\n  if c.failures[key] < c.threshold { return }\n  opened, ok := c.openedAt[key]\n  if !ok || c.now().Sub(opened) >= c.cooldown { c.openedAt[key] = c.now() }\nopen() and recordSuccess() semantics unchanged (recordSuccess still clears both maps = closes the circuit).\n\nVERIFICATION: (1) the reporter-supplied failing test now passes as a permanent regression test (TestCircuit_FailedProbeReopens: 3 failures -> open true; +301s -> probe allowed; probe fails -> open true again). (2) TestCircuit_FailureInsideOpenWindowDoesNotExtend: threshold 2, cooldown 300s, a failure recorded 10s INTO the open window leaves open() true at t0+299s and false at t0+300s, so the window is not extended. (3) TestCircuit_SuccessClosesAndResets. Adversarial check: reverting only circuit.go to the pre-fix revision makes TestCircuit_FailedProbeReopens FAIL ('circuit did not re-open after failed probe') and restoring it makes it PASS, so the test is not vacuous. Full repo: go build ./... 0, go vet ./... 0, go test ./... -count=1 all 13 packages ok; gitreins tier-1 guard PASS (secrets/build/lint/tests).\n\nGENERAL RULE: a breaker stores WHEN it tripped, so the 'arm' operation must be idempotent only for the ACTIVE window. Gate the stamping on window expiry (or absence), never on key absence alone; otherwise the state machine has an absorbing state that silently disables the protection it exists to provide.", "environment": "Go 1.2x, per-process in-memory circuit breaker (sync.Mutex + map[string]int failures + map[string]time.Time openedAt) with an injectable now() clock; unit tested with a frozen clock (no sleeps)", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-circuit-breaker-failed-probe-rearm", "provider": "openrouter", "solved_at": "2026-09-16T06:49:47.103Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog