JuliusBrussee/caveman · error
cave_breaker_retry_backoff_invalid
cave_breaker_retry_backoff_invalid
Error message
cave_breaker_retry_backoff_invalid
What it means
normalizeRunBreakers validates retry.backoffMs, defaulting to DEFAULT_RETRY_BACKOFF_MS when omitted. The value must be a non-negative safe integer of milliseconds; backoff drives the deterministic exponential delay between retries, so fractional, negative, non-integer, or out-of-safe-integer values are rejected with this sentinel error.
Solutions
- Set backoffMs to a whole, non-negative millisecond count (e.g. 500).
- Omit backoffMs entirely to use DEFAULT_RETRY_BACKOFF_MS.
- Convert seconds/config strings to integer ms with Number.parseInt before passing them in.
Example fix
// before
retry: { maxSpend: 0.5, backoffMs: 0.5 }
// after
retry: { maxSpend: 0.5, backoffMs: 500 } Defensive patterns
Strategy: validation
Validate before calling
function validBackoffMs(v) { return v === undefined || (Number.isSafeInteger(v) && v >= 0); }
if (breakers.retry && !validBackoffMs(breakers.retry.backoffMs)) throw new Error('cave_breaker_retry_backoff_invalid: need safe integer ms >= 0'); Type guard
const isMsDuration = (v: unknown): v is number => Number.isSafeInteger(v) && v >= 0;
Try / catch
try { normalizeRunBreakers(cfg); } catch (e) {
if (e.message === 'cave_breaker_retry_backoff_invalid') { cfg.breakers.retry.backoffMs = Math.max(0, Math.round(Number(cfg.breakers.retry.backoffMs))); }
else throw e;
} Prevention
- Express durations in whole milliseconds; convert seconds with Math.round(s * 1000).
- Coerce config strings to integers before constructing breakers.
- Omit backoffMs to accept the library default.
When it happens
Trigger: Passing `breakers.retry.backoffMs` as a negative number, a float (e.g. 1.5), a non-numeric string coerced to NaN, or a value beyond Number.MAX_SAFE_INTEGER.
Common situations: Specifying backoff in seconds instead of milliseconds (0.5); computing backoff with arithmetic that yields NaN from a bad config string; copy-pasting durations with units ('500ms' as a string).
Understand the failure class
Background: "invalid duration" / "failed to parse duration": why your timeout, interval, or TTL string is rejected and which formats each library accepts — this error's family across 32 libraries.
Related errors
- cave_breaker_retry_requires_budget
- cave_breaker_retry_spend_invalid
- cave_breaker_threshold_invalid
- cave_budget_denomination_ambiguous
- cave_budget_initial_invalid
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/f9f75912fdf40ec7.
Report an issue: GitHub.
Appendix: 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 3ee70a1026)