JuliusBrussee/caveman · error · Error

cave_breaker_retry_backoff_invalid

Error message

cave_breaker_retry_backoff_invalid

What it means

The retry backoff (`breakers.retry.backoffMs`, default 250ms — first backoff, doubled each attempt, deliberately no jitter) must be a safe integer ≥ 0. `normalizeRunBreakers` throws `cave_breaker_retry_backoff_invalid` for fractional milliseconds, negatives, `NaN`, `Infinity`, or unsafe-large integers. Zero is allowed and means the first retry is immediate.

Source

Thrown at packages/agent/src/breakers.ts:97

    repeatedToolCalls,
    repeatedToolCallWindowTurns,
    noProgressTurns,
    maxToolCallsPerTurn,
  ]) {
    if (!Number.isSafeInteger(value) || value <= 0) {
      throw new Error("cave_breaker_threshold_invalid");
    }
  }
  if (breakers.retry !== undefined && !hasBudget) {
    throw new Error("cave_breaker_retry_requires_budget");
  }
  if (breakers.retry !== undefined &&
      (!Number.isFinite(breakers.retry.maxSpend) || breakers.retry.maxSpend <= 0)) {
    throw new Error("cave_breaker_retry_spend_invalid");
  }
  const retryBackoffMs = breakers.retry?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
  if (!Number.isSafeInteger(retryBackoffMs) || retryBackoffMs < 0) {
    throw new Error("cave_breaker_retry_backoff_invalid");
  }
  return Object.freeze({
    repeatedToolCalls,
    repeatedToolCallWindowTurns,
    noProgressTurns,
    maxToolCallsPerTurn,
    retryMaxSpend: breakers.retry?.maxSpend,
    retryBackoffMs,
  });
}

/** One breaker decision, recorded on the receipt so a break is never silent. */
export interface BreakerEvent {
  readonly kind: "loop_detected" | "no_progress" | "fan_out_blocked" | "retry_attempted" | "retry_exhausted";
  readonly tool: string | undefined;
  /** Repeats for a loop, identical turns for no-progress, blocked calls for fan-out, attempt number for a retry. */
  readonly count: number;
  /** The call hash for a loop break, so the offending window is identifiable. */

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Round to a whole number: `backoffMs: Math.round(1000 / 3)` → `333`.
  2. Use 0 only if you intentionally want no initial delay.
  3. Omit `backoffMs` entirely to accept the 250ms default.

Example fix

// before
breakers: { retry: { maxSpend: 1, backoffMs: 1000 / 3 } } // 333.333...

// after
breakers: { retry: { maxSpend: 1, backoffMs: Math.round(1000 / 3) } } // 333
Defensive patterns

Strategy: validation

Validate before calling

const backoffMs = options.breakers?.retry?.backoffMs ?? 250; // default
if (!Number.isSafeInteger(backoffMs) || backoffMs < 0) {
  throw new Error("breakers.retry.backoffMs must be a safe integer >= 0");
}

Type guard

function isValidBackoffMs(v: unknown): v is number {
  return Number.isSafeInteger(v) && (v as number) >= 0;
}

Try / catch

try {
  normalizeRunBreakers(breakers, true);
} catch (err) {
  if (err instanceof Error && err.message === "cave_breaker_retry_backoff_invalid") {
    // round the computed backoff, or omit the field to take the 250ms default
  }
}

Prevention

When it happens

Trigger: Setting `backoffMs: 250.5` (fraction), `backoffMs: -1`, or `backoffMs: Infinity`; deriving backoff from a division (`1000 / 3`) without rounding; parsing from a string config.

Common situations: Computing backoff from environment-scaled formulas; passing a float-typed config field through JSON; assuming any number works because the default is optional.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/f9f75912fdf40ec7. Report an issue: GitHub.