Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,14 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# OPTIONAL TUNING
# =============================================================================

# Max number of API retries on failure (default: 10)
# CLAUDE_CODE_MAX_RETRIES=10
# Max number of API retries on failure (default: 10, cap: 100)
# Set to 0 to disable retries after the initial request
# Deprecated fallback when OPENCLAUDE_MAX_RETRIES is unset: CLAUDE_CODE_MAX_RETRIES
# OPENCLAUDE_MAX_RETRIES=10

# Base retry delay in milliseconds when the API does not send Retry-After
# Uses exponential backoff from this value with jitter (default: 500, cap: 60000)
# OPENCLAUDE_RETRY_DELAY_MS=500

# Enable persistent retry mode for unattended/CI sessions
# Retries 429/529 indefinitely with smart backoff
Expand Down
2 changes: 2 additions & 0 deletions docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ export OPENAI_MODEL=gpt-4o
| `CHATGPT_ACCOUNT_ID` / `CODEX_ACCOUNT_ID` | Codex only | Required for manual Codex env setup when the account id is not coming from `auth.json` or stored OAuth credentials |
| `CODEX_AUTH_JSON_PATH` | Codex only | Path to a Codex CLI `auth.json` file |
| `CODEX_HOME` | Codex only | Alternative Codex home directory |
| `OPENCLAUDE_MAX_RETRIES` | No | Maximum retry attempts for retryable API failures, capped at 100 (default: 10). Set to `0` to disable retries after the initial request. If unset, deprecated `CLAUDE_CODE_MAX_RETRIES` is still honored for compatibility. |
| `OPENCLAUDE_RETRY_DELAY_MS` | No | Base retry delay in milliseconds for APIs that do not send `Retry-After`; exponential backoff starts from this value, capped at 60000 (default: 500) |
| `OPENCLAUDE_DISABLE_CO_AUTHORED_BY` | No | Suppress the default `Co-Authored-By` trailer in generated git commits |
| `OPENCLAUDE_LOG_TOKEN_USAGE` | No | When truthy (e.g. `verbose`), emits one JSON line on stderr per API request with input/output/cache tokens and the resolved provider. **User-facing debug output** — complements the REPL display controlled by `/config showCacheStats`. Distinct from `CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, which is **model-facing** (injects context usage info into the prompt itself). Both can run together. |

Expand Down
83 changes: 83 additions & 0 deletions src/services/api/withRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const envKeys = [
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_MAX_RETRIES',
'OPENCLAUDE_MAX_RETRIES',
'OPENCLAUDE_RETRY_DELAY_MS',
'OPENAI_MODEL',
'OPENAI_BASE_URL',
'OPENAI_API_BASE',
Expand Down Expand Up @@ -70,6 +73,86 @@ async function importFreshWithRetryModule(
return import(`./withRetry.js?ts=${Date.now()}-${Math.random()}`)
}

describe('retry configuration', () => {
test('uses default retry attempts when env var is absent', async () => {
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(10)
})

test('reads retry attempts from OPENCLAUDE_MAX_RETRIES', async () => {
process.env.OPENCLAUDE_MAX_RETRIES = '4'
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(4)
})

test('allows zero retry attempts', async () => {
process.env.OPENCLAUDE_MAX_RETRIES = '0'
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(0)
})

test('falls back to legacy CLAUDE_CODE_MAX_RETRIES when new env var is absent', async () => {
process.env.CLAUDE_CODE_MAX_RETRIES = '0'
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(0)
})

test('prefers OPENCLAUDE_MAX_RETRIES over legacy CLAUDE_CODE_MAX_RETRIES', async () => {
process.env.OPENCLAUDE_MAX_RETRIES = '3'
process.env.CLAUDE_CODE_MAX_RETRIES = '0'
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(3)
})

test('falls back to default retry attempts for invalid values', async () => {
process.env.OPENCLAUDE_MAX_RETRIES = 'nope'
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(10)
})

test('caps retry attempts to a bounded value', async () => {
process.env.OPENCLAUDE_MAX_RETRIES = '1000'
const { getDefaultMaxRetries } = await importFreshWithRetryModule()
expect(getDefaultMaxRetries()).toBe(100)
})

test('uses default retry delay when env var is absent', async () => {
const { getDefaultRetryDelayMs } = await importFreshWithRetryModule()
expect(getDefaultRetryDelayMs()).toBe(500)
})

test('reads retry delay from OPENCLAUDE_RETRY_DELAY_MS', async () => {
process.env.OPENCLAUDE_RETRY_DELAY_MS = '1500'
const { getDefaultRetryDelayMs } = await importFreshWithRetryModule()
expect(getDefaultRetryDelayMs()).toBe(1500)
})

test('falls back to default retry delay for invalid values', async () => {
process.env.OPENCLAUDE_RETRY_DELAY_MS = '-1'
const { getDefaultRetryDelayMs } = await importFreshWithRetryModule()
expect(getDefaultRetryDelayMs()).toBe(500)
})

test('uses configured retry delay as exponential backoff base', async () => {
process.env.OPENCLAUDE_RETRY_DELAY_MS = '2000'
const originalRandom = Math.random
Math.random = () => 0
try {
const { getRetryDelay } = await importFreshWithRetryModule()
expect(getRetryDelay(1)).toBe(2000)
expect(getRetryDelay(2)).toBe(4000)
} finally {
Math.random = originalRandom
}
})

test('retry-after header takes precedence over configured delay', async () => {
process.env.OPENCLAUDE_RETRY_DELAY_MS = '2000'
const { getRetryDelay } = await importFreshWithRetryModule()
expect(getRetryDelay(1, '3')).toBe(3000)
})
})

// --- parseOpenAIDuration ---
describe('parseOpenAIDuration', () => {
test('parses seconds: "1s" → 1000', async () => {
Expand Down
61 changes: 57 additions & 4 deletions src/services/api/withRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isEnterpriseSubscriber,
} from '../../utils/auth.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { validateBoundedIntEnvVar } from '../../utils/envValidation.js'
import { errorMessage } from '../../utils/errors.js'
import {
type CooldownReason,
Expand Down Expand Up @@ -50,9 +51,12 @@ import { extractConnectionErrorDetails } from './errorUtils.js'
const abortError = () => new APIUserAbortError()

const DEFAULT_MAX_RETRIES = 10
const MAX_CONFIGURABLE_RETRIES = 100
const FLOOR_OUTPUT_TOKENS = 3000
const MAX_529_RETRIES = 3
export const BASE_DELAY_MS = 500
export const DEFAULT_RETRY_DELAY_MS = 500
export const BASE_DELAY_MS = DEFAULT_RETRY_DELAY_MS
const MAX_RETRY_DELAY_BASE_MS = 60_000

// Foreground query sources where the user IS blocking on the result — these
// retry on 529. Everything else (summaries, titles, suggestions, classifiers)
Expand Down Expand Up @@ -582,8 +586,9 @@ export function getRetryDelay(
}
}

const baseDelayMs = getDefaultRetryDelayMs()
const baseDelay = Math.min(
BASE_DELAY_MS * Math.pow(2, attempt - 1),
baseDelayMs * Math.pow(2, attempt - 1),
maxDelayMs,
)
const jitter = Math.random() * 0.25 * baseDelay
Expand Down Expand Up @@ -872,15 +877,63 @@ function shouldRetry(error: APIError): boolean {
}

export function getDefaultMaxRetries(): number {
if (process.env.CLAUDE_CODE_MAX_RETRIES) {
return parseInt(process.env.CLAUDE_CODE_MAX_RETRIES, 10)
const openClaudeMaxRetries = process.env.OPENCLAUDE_MAX_RETRIES
if (openClaudeMaxRetries) {
return validateRetryAttemptsEnvVar(
'OPENCLAUDE_MAX_RETRIES',
openClaudeMaxRetries,
)
}

const legacyMaxRetries = process.env.CLAUDE_CODE_MAX_RETRIES
if (legacyMaxRetries) {
logForDebugging(
'CLAUDE_CODE_MAX_RETRIES is deprecated; use OPENCLAUDE_MAX_RETRIES instead',
)
return validateRetryAttemptsEnvVar(
'CLAUDE_CODE_MAX_RETRIES',
legacyMaxRetries,
)
}

return DEFAULT_MAX_RETRIES
}

export function getDefaultRetryDelayMs(): number {
return validateBoundedIntEnvVar(
'OPENCLAUDE_RETRY_DELAY_MS',
process.env.OPENCLAUDE_RETRY_DELAY_MS,
DEFAULT_RETRY_DELAY_MS,
MAX_RETRY_DELAY_BASE_MS,
).effective
}
function getMaxRetries(options: RetryOptions): number {
return options.maxRetries ?? getDefaultMaxRetries()
}

function validateRetryAttemptsEnvVar(
envVarName: string,
value: string | undefined,
): number {
if (!value) {
return DEFAULT_MAX_RETRIES
}
const parsed = parseInt(value, 10)
if (isNaN(parsed) || parsed < 0) {
logForDebugging(
`${envVarName} Invalid value "${value}" (using default: ${DEFAULT_MAX_RETRIES})`,
)
return DEFAULT_MAX_RETRIES
}
if (parsed > MAX_CONFIGURABLE_RETRIES) {
logForDebugging(
`${envVarName} Capped from ${parsed} to ${MAX_CONFIGURABLE_RETRIES}`,
)
return MAX_CONFIGURABLE_RETRIES
}
return parsed
}

const DEFAULT_FAST_MODE_FALLBACK_HOLD_MS = 30 * 60 * 1000 // 30 minutes
const SHORT_RETRY_THRESHOLD_MS = 20 * 1000 // 20 seconds
const MIN_COOLDOWN_MS = 10 * 60 * 1000 // 10 minutes
Expand Down