| import { feature } from 'bun:bundle' |
| import type Anthropic from '@anthropic-ai/sdk' |
| import { |
| APIConnectionError, |
| APIError, |
| APIUserAbortError, |
| } from '@anthropic-ai/sdk' |
| import type { QuerySource } from 'src/constants/querySource.js' |
| import type { SystemAPIErrorMessage } from 'src/types/message.js' |
| import { isAwsCredentialsProviderError } from 'src/utils/aws.js' |
| import { logForDebugging } from 'src/utils/debug.js' |
| import { logError } from 'src/utils/log.js' |
| import { createSystemAPIErrorMessage } from 'src/utils/messages.js' |
| import { getAPIProviderForStatsig } from 'src/utils/model/providers.js' |
| import { |
| clearApiKeyHelperCache, |
| clearAwsCredentialsCache, |
| clearGcpCredentialsCache, |
| getClaudeAIOAuthTokens, |
| handleOAuth401Error, |
| isClaudeAISubscriber, |
| isEnterpriseSubscriber, |
| } from '../../utils/auth.js' |
| import { isEnvTruthy } from '../../utils/envUtils.js' |
| import { errorMessage } from '../../utils/errors.js' |
| import { |
| type CooldownReason, |
| handleFastModeOverageRejection, |
| handleFastModeRejectedByAPI, |
| isFastModeCooldown, |
| isFastModeEnabled, |
| triggerFastModeCooldown, |
| } from '../../utils/fastMode.js' |
| import { isNonCustomOpusModel } from '../../utils/model/model.js' |
| import { disableKeepAlive } from '../../utils/proxy.js' |
| import { sleep } from '../../utils/sleep.js' |
| import type { ThinkingConfig } from '../../utils/thinking.js' |
| import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' |
| import { |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| logEvent, |
| } from '../analytics/index.js' |
| import { |
| checkMockRateLimitError, |
| isMockRateLimitError, |
| } from '../rateLimitMocking.js' |
| import { REPEATED_529_ERROR_MESSAGE } from './errors.js' |
| import { extractConnectionErrorDetails } from './errorUtils.js' |
|
|
| const abortError = () => new APIUserAbortError() |
|
|
| const DEFAULT_MAX_RETRIES = 10 |
| const FLOOR_OUTPUT_TOKENS = 3000 |
| const MAX_529_RETRIES = 3 |
| export const BASE_DELAY_MS = 500 |
|
|
| |
| |
| |
| |
| |
| const FOREGROUND_529_RETRY_SOURCES = new Set<QuerySource>([ |
| 'repl_main_thread', |
| 'repl_main_thread:outputStyle:custom', |
| 'repl_main_thread:outputStyle:Explanatory', |
| 'repl_main_thread:outputStyle:Learning', |
| 'sdk', |
| 'agent:custom', |
| 'agent:default', |
| 'agent:builtin', |
| 'compact', |
| 'hook_agent', |
| 'hook_prompt', |
| 'verification_agent', |
| 'side_question', |
| |
| |
| |
| |
| 'auto_mode', |
| ...(feature('BASH_CLASSIFIER') ? (['bash_classifier'] as const) : []), |
| ]) |
|
|
| function shouldRetry529(querySource: QuerySource | undefined): boolean { |
| |
| return ( |
| querySource === undefined || FOREGROUND_529_RETRY_SOURCES.has(querySource) |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| const PERSISTENT_MAX_BACKOFF_MS = 5 * 60 * 1000 |
| const PERSISTENT_RESET_CAP_MS = 6 * 60 * 60 * 1000 |
| const HEARTBEAT_INTERVAL_MS = 30_000 |
|
|
| function isPersistentRetryEnabled(): boolean { |
| return feature('UNATTENDED_RETRY') |
| ? isEnvTruthy(process.env.CLAUDE_CODE_UNATTENDED_RETRY) |
| : false |
| } |
|
|
| function isTransientCapacityError(error: unknown): boolean { |
| return ( |
| is529Error(error) || (error instanceof APIError && error.status === 429) |
| ) |
| } |
|
|
| function isStaleConnectionError(error: unknown): boolean { |
| if (!(error instanceof APIConnectionError)) { |
| return false |
| } |
| const details = extractConnectionErrorDetails(error) |
| return details?.code === 'ECONNRESET' || details?.code === 'EPIPE' |
| } |
|
|
| export interface RetryContext { |
| maxTokensOverride?: number |
| model: string |
| thinkingConfig: ThinkingConfig |
| fastMode?: boolean |
| } |
|
|
| interface RetryOptions { |
| maxRetries?: number |
| model: string |
| fallbackModel?: string |
| thinkingConfig: ThinkingConfig |
| fastMode?: boolean |
| signal?: AbortSignal |
| querySource?: QuerySource |
| |
| |
| |
| |
| |
| |
| initialConsecutive529Errors?: number |
| } |
|
|
| export class CannotRetryError extends Error { |
| constructor( |
| public readonly originalError: unknown, |
| public readonly retryContext: RetryContext, |
| ) { |
| const message = errorMessage(originalError) |
| super(message) |
| this.name = 'RetryError' |
|
|
| |
| if (originalError instanceof Error && originalError.stack) { |
| this.stack = originalError.stack |
| } |
| } |
| } |
|
|
| export class FallbackTriggeredError extends Error { |
| constructor( |
| public readonly originalModel: string, |
| public readonly fallbackModel: string, |
| ) { |
| super(`Model fallback triggered: ${originalModel} -> ${fallbackModel}`) |
| this.name = 'FallbackTriggeredError' |
| } |
| } |
|
|
| export async function* withRetry<T>( |
| getClient: () => Promise<Anthropic>, |
| operation: ( |
| client: Anthropic, |
| attempt: number, |
| context: RetryContext, |
| ) => Promise<T>, |
| options: RetryOptions, |
| ): AsyncGenerator<SystemAPIErrorMessage, T> { |
| const maxRetries = getMaxRetries(options) |
| const retryContext: RetryContext = { |
| model: options.model, |
| thinkingConfig: options.thinkingConfig, |
| ...(isFastModeEnabled() && { fastMode: options.fastMode }), |
| } |
| let client: Anthropic | null = null |
| let consecutive529Errors = options.initialConsecutive529Errors ?? 0 |
| let lastError: unknown |
| let persistentAttempt = 0 |
| for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { |
| if (options.signal?.aborted) { |
| throw new APIUserAbortError() |
| } |
|
|
| |
| |
| const wasFastModeActive = isFastModeEnabled() |
| ? retryContext.fastMode && !isFastModeCooldown() |
| : false |
|
|
| try { |
| |
| if (process.env.USER_TYPE === 'ant') { |
| const mockError = checkMockRateLimitError( |
| retryContext.model, |
| wasFastModeActive, |
| ) |
| if (mockError) { |
| throw mockError |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const isStaleConnection = isStaleConnectionError(lastError) |
| if ( |
| isStaleConnection && |
| getFeatureValue_CACHED_MAY_BE_STALE( |
| 'tengu_disable_keepalive_on_econnreset', |
| false, |
| ) |
| ) { |
| logForDebugging( |
| 'Stale connection (ECONNRESET/EPIPE) — disabling keep-alive for retry', |
| ) |
| disableKeepAlive() |
| } |
|
|
| if ( |
| client === null || |
| (lastError instanceof APIError && lastError.status === 401) || |
| isOAuthTokenRevokedError(lastError) || |
| isBedrockAuthError(lastError) || |
| isVertexAuthError(lastError) || |
| isStaleConnection |
| ) { |
| |
| if ( |
| (lastError instanceof APIError && lastError.status === 401) || |
| isOAuthTokenRevokedError(lastError) |
| ) { |
| const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken |
| if (failedAccessToken) { |
| await handleOAuth401Error(failedAccessToken) |
| } |
| } |
| client = await getClient() |
| } |
|
|
| return await operation(client, attempt, retryContext) |
| } catch (error) { |
| lastError = error |
| logForDebugging( |
| `API error (attempt ${attempt}/${maxRetries + 1}): ${error instanceof APIError ? `${error.status} ${error.message}` : errorMessage(error)}`, |
| { level: 'error' }, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| if ( |
| wasFastModeActive && |
| !isPersistentRetryEnabled() && |
| error instanceof APIError && |
| (error.status === 429 || is529Error(error)) |
| ) { |
| |
| |
| const overageReason = (() => { |
| const headers = error.headers as unknown |
| const key = 'anthropic-ratelimit-unified-overage-disabled-reason' |
| if (!headers) return undefined |
| if (typeof (headers as Headers).get === 'function') { |
| return (headers as Headers).get(key) ?? undefined |
| } |
| if (typeof headers === 'object') { |
| const obj = headers as Record<string, unknown> |
| const value = obj[key] |
| return typeof value === 'string' ? value : undefined |
| } |
| return undefined |
| })() |
| if (overageReason !== null && overageReason !== undefined) { |
| handleFastModeOverageRejection(overageReason) |
| retryContext.fastMode = false |
| continue |
| } |
|
|
| const retryAfterMs = getRetryAfterMs(error) |
| if (retryAfterMs !== null && retryAfterMs < SHORT_RETRY_THRESHOLD_MS) { |
| |
| |
| await sleep(retryAfterMs, options.signal, { abortError }) |
| continue |
| } |
| |
| |
| const cooldownMs = Math.max( |
| retryAfterMs ?? DEFAULT_FAST_MODE_FALLBACK_HOLD_MS, |
| MIN_COOLDOWN_MS, |
| ) |
| const cooldownReason: CooldownReason = is529Error(error) |
| ? 'overloaded' |
| : 'rate_limit' |
| triggerFastModeCooldown(Date.now() + cooldownMs, cooldownReason) |
| if (isFastModeEnabled()) { |
| retryContext.fastMode = false |
| } |
| continue |
| } |
|
|
| |
| |
| |
| if (wasFastModeActive && isFastModeNotEnabledError(error)) { |
| handleFastModeRejectedByAPI() |
| retryContext.fastMode = false |
| continue |
| } |
|
|
| |
| |
| if (is529Error(error) && !shouldRetry529(options.querySource)) { |
| logEvent('tengu_api_529_background_dropped', { |
| query_source: |
| options.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| }) |
| throw new CannotRetryError(error, retryContext) |
| } |
|
|
| |
| if ( |
| is529Error(error) && |
| |
| |
| (process.env.FALLBACK_FOR_ALL_PRIMARY_MODELS || |
| (!isClaudeAISubscriber() && isNonCustomOpusModel(options.model))) |
| ) { |
| consecutive529Errors++ |
| if (consecutive529Errors >= MAX_529_RETRIES) { |
| |
| if (options.fallbackModel) { |
| logEvent('tengu_api_opus_fallback_triggered', { |
| original_model: |
| options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| fallback_model: |
| options.fallbackModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| provider: getAPIProviderForStatsig(), |
| }) |
|
|
| |
| throw new FallbackTriggeredError( |
| options.model, |
| options.fallbackModel, |
| ) |
| } |
|
|
| if ( |
| process.env.USER_TYPE === 'external' && |
| !process.env.IS_SANDBOX && |
| !isPersistentRetryEnabled() |
| ) { |
| logEvent('tengu_api_custom_529_overloaded_error', {}) |
| throw new CannotRetryError( |
| new Error(REPEATED_529_ERROR_MESSAGE), |
| retryContext, |
| ) |
| } |
| } |
| } |
|
|
| |
| const persistent = |
| isPersistentRetryEnabled() && isTransientCapacityError(error) |
| if (attempt > maxRetries && !persistent) { |
| throw new CannotRetryError(error, retryContext) |
| } |
|
|
| |
| const handledCloudAuthError = |
| handleAwsCredentialError(error) || handleGcpCredentialError(error) |
| if ( |
| !handledCloudAuthError && |
| (!(error instanceof APIError) || !shouldRetry(error)) |
| ) { |
| throw new CannotRetryError(error, retryContext) |
| } |
|
|
| |
| |
| |
| |
| if (error instanceof APIError) { |
| const overflowData = parseMaxTokensContextOverflowError(error) |
| if (overflowData) { |
| const { inputTokens, contextLimit } = overflowData |
|
|
| const safetyBuffer = 1000 |
| const availableContext = Math.max( |
| 0, |
| contextLimit - inputTokens - safetyBuffer, |
| ) |
| if (availableContext < FLOOR_OUTPUT_TOKENS) { |
| logError( |
| new Error( |
| `availableContext ${availableContext} is less than FLOOR_OUTPUT_TOKENS ${FLOOR_OUTPUT_TOKENS}`, |
| ), |
| ) |
| throw error |
| } |
| |
| const minRequired = |
| (retryContext.thinkingConfig.type === 'enabled' |
| ? retryContext.thinkingConfig.budgetTokens |
| : 0) + 1 |
| const adjustedMaxTokens = Math.max( |
| FLOOR_OUTPUT_TOKENS, |
| availableContext, |
| minRequired, |
| ) |
| retryContext.maxTokensOverride = adjustedMaxTokens |
|
|
| logEvent('tengu_max_tokens_context_overflow_adjustment', { |
| inputTokens, |
| contextLimit, |
| adjustedMaxTokens, |
| attempt, |
| }) |
|
|
| continue |
| } |
| } |
|
|
| |
| |
| const retryAfter = getRetryAfter(error) |
| let delayMs: number |
| if (persistent && error instanceof APIError && error.status === 429) { |
| persistentAttempt++ |
| |
| |
| const resetDelay = getRateLimitResetDelayMs(error) |
| delayMs = |
| resetDelay ?? |
| Math.min( |
| getRetryDelay( |
| persistentAttempt, |
| retryAfter, |
| PERSISTENT_MAX_BACKOFF_MS, |
| ), |
| PERSISTENT_RESET_CAP_MS, |
| ) |
| } else if (persistent) { |
| persistentAttempt++ |
| |
| |
| |
| delayMs = Math.min( |
| getRetryDelay( |
| persistentAttempt, |
| retryAfter, |
| PERSISTENT_MAX_BACKOFF_MS, |
| ), |
| PERSISTENT_RESET_CAP_MS, |
| ) |
| } else { |
| delayMs = getRetryDelay(attempt, retryAfter) |
| } |
|
|
| |
| |
| const reportedAttempt = persistent ? persistentAttempt : attempt |
| logEvent('tengu_api_retry', { |
| attempt: reportedAttempt, |
| delayMs: delayMs, |
| error: (error as APIError) |
| .message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| status: (error as APIError).status, |
| provider: getAPIProviderForStatsig(), |
| }) |
|
|
| if (persistent) { |
| if (delayMs > 60_000) { |
| logEvent('tengu_api_persistent_retry_wait', { |
| status: (error as APIError).status, |
| delayMs, |
| attempt: reportedAttempt, |
| provider: getAPIProviderForStatsig(), |
| }) |
| } |
| |
| |
| |
| let remaining = delayMs |
| while (remaining > 0) { |
| if (options.signal?.aborted) throw new APIUserAbortError() |
| if (error instanceof APIError) { |
| yield createSystemAPIErrorMessage( |
| error, |
| remaining, |
| reportedAttempt, |
| maxRetries, |
| ) |
| } |
| const chunk = Math.min(remaining, HEARTBEAT_INTERVAL_MS) |
| await sleep(chunk, options.signal, { abortError }) |
| remaining -= chunk |
| } |
| |
| |
| if (attempt >= maxRetries) attempt = maxRetries |
| } else { |
| if (error instanceof APIError) { |
| yield createSystemAPIErrorMessage(error, delayMs, attempt, maxRetries) |
| } |
| await sleep(delayMs, options.signal, { abortError }) |
| } |
| } |
| } |
|
|
| throw new CannotRetryError(lastError, retryContext) |
| } |
|
|
| function getRetryAfter(error: unknown): string | null { |
| return ( |
| ((error as { headers?: { 'retry-after'?: string } }).headers?.[ |
| 'retry-after' |
| ] || |
| |
| ((error as APIError).headers as Headers)?.get?.('retry-after')) ?? |
| null |
| ) |
| } |
|
|
| export function getRetryDelay( |
| attempt: number, |
| retryAfterHeader?: string | null, |
| maxDelayMs = 32000, |
| ): number { |
| if (retryAfterHeader) { |
| const seconds = parseInt(retryAfterHeader, 10) |
| if (!isNaN(seconds)) { |
| return seconds * 1000 |
| } |
| } |
|
|
| const baseDelay = Math.min( |
| BASE_DELAY_MS * Math.pow(2, attempt - 1), |
| maxDelayMs, |
| ) |
| const jitter = Math.random() * 0.25 * baseDelay |
| return baseDelay + jitter |
| } |
|
|
| export function parseMaxTokensContextOverflowError(error: APIError): |
| | { |
| inputTokens: number |
| maxTokens: number |
| contextLimit: number |
| } |
| | undefined { |
| if (error.status !== 400 || !error.message) { |
| return undefined |
| } |
|
|
| if ( |
| !error.message.includes( |
| 'input length and `max_tokens` exceed context limit', |
| ) |
| ) { |
| return undefined |
| } |
|
|
| |
| const regex = |
| /input length and `max_tokens` exceed context limit: (\d+) \+ (\d+) > (\d+)/ |
| const match = error.message.match(regex) |
|
|
| if (!match || match.length !== 4) { |
| return undefined |
| } |
|
|
| if (!match[1] || !match[2] || !match[3]) { |
| logError( |
| new Error( |
| 'Unable to parse max_tokens from max_tokens exceed context limit error message', |
| ), |
| ) |
| return undefined |
| } |
| const inputTokens = parseInt(match[1], 10) |
| const maxTokens = parseInt(match[2], 10) |
| const contextLimit = parseInt(match[3], 10) |
|
|
| if (isNaN(inputTokens) || isNaN(maxTokens) || isNaN(contextLimit)) { |
| return undefined |
| } |
|
|
| return { inputTokens, maxTokens, contextLimit } |
| } |
|
|
| |
| |
| |
| function isFastModeNotEnabledError(error: unknown): boolean { |
| if (!(error instanceof APIError)) { |
| return false |
| } |
| return ( |
| error.status === 400 && |
| (error.message?.includes('Fast mode is not enabled') ?? false) |
| ) |
| } |
|
|
| export function is529Error(error: unknown): boolean { |
| if (!(error instanceof APIError)) { |
| return false |
| } |
|
|
| |
| return ( |
| error.status === 529 || |
| |
| (error.message?.includes('"type":"overloaded_error"') ?? false) |
| ) |
| } |
|
|
| function isOAuthTokenRevokedError(error: unknown): boolean { |
| return ( |
| error instanceof APIError && |
| error.status === 403 && |
| (error.message?.includes('OAuth token has been revoked') ?? false) |
| ) |
| } |
|
|
| function isBedrockAuthError(error: unknown): boolean { |
| if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) { |
| |
| |
| |
| if ( |
| isAwsCredentialsProviderError(error) || |
| (error instanceof APIError && error.status === 403) |
| ) { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| |
| |
| |
| |
| function handleAwsCredentialError(error: unknown): boolean { |
| if (isBedrockAuthError(error)) { |
| clearAwsCredentialsCache() |
| return true |
| } |
| return false |
| } |
|
|
| |
| |
| function isGoogleAuthLibraryCredentialError(error: unknown): boolean { |
| if (!(error instanceof Error)) return false |
| const msg = error.message |
| return ( |
| msg.includes('Could not load the default credentials') || |
| msg.includes('Could not refresh access token') || |
| msg.includes('invalid_grant') |
| ) |
| } |
|
|
| function isVertexAuthError(error: unknown): boolean { |
| if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) { |
| |
| if (isGoogleAuthLibraryCredentialError(error)) { |
| return true |
| } |
| |
| if (error instanceof APIError && error.status === 401) { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| |
| |
| |
| |
| function handleGcpCredentialError(error: unknown): boolean { |
| if (isVertexAuthError(error)) { |
| clearGcpCredentialsCache() |
| return true |
| } |
| return false |
| } |
|
|
| function shouldRetry(error: APIError): boolean { |
| |
| if (isMockRateLimitError(error)) { |
| return false |
| } |
|
|
| |
| |
| if (isPersistentRetryEnabled() && isTransientCapacityError(error)) { |
| return true |
| } |
|
|
| |
| |
| |
| |
| if ( |
| isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && |
| (error.status === 401 || error.status === 403) |
| ) { |
| return true |
| } |
|
|
| |
| |
| |
| if (error.message?.includes('"type":"overloaded_error"')) { |
| return true |
| } |
|
|
| |
| if (parseMaxTokensContextOverflowError(error)) { |
| return true |
| } |
|
|
| |
| |
| |
| |
| const shouldRetryHeader = (() => { |
| const headers = error.headers as unknown |
| if (!headers) return undefined |
| if (typeof (headers as Headers).get === 'function') { |
| return (headers as Headers).get('x-should-retry') ?? undefined |
| } |
| if (typeof headers === 'object') { |
| const obj = headers as Record<string, unknown> |
| const value = obj['x-should-retry'] ?? obj['X-Should-Retry'] |
| return typeof value === 'string' ? value : undefined |
| } |
| return undefined |
| })() |
|
|
| |
| |
| |
| if ( |
| shouldRetryHeader === 'true' && |
| (!isClaudeAISubscriber() || isEnterpriseSubscriber()) |
| ) { |
| return true |
| } |
|
|
| |
| |
| if (shouldRetryHeader === 'false') { |
| const is5xxError = error.status !== undefined && error.status >= 500 |
| if (!(process.env.USER_TYPE === 'ant' && is5xxError)) { |
| return false |
| } |
| } |
|
|
| if (error instanceof APIConnectionError) { |
| return true |
| } |
|
|
| if (!error.status) return false |
|
|
| |
| if (error.status === 408) return true |
|
|
| |
| if (error.status === 409) return true |
|
|
| |
| |
| if (error.status === 429) { |
| return !isClaudeAISubscriber() || isEnterpriseSubscriber() |
| } |
|
|
| |
| |
| if (error.status === 401) { |
| clearApiKeyHelperCache() |
| return true |
| } |
|
|
| |
| if (isOAuthTokenRevokedError(error)) { |
| return true |
| } |
|
|
| |
| if (error.status && error.status >= 500) return true |
|
|
| return false |
| } |
|
|
| export function getDefaultMaxRetries(): number { |
| if (process.env.CLAUDE_CODE_MAX_RETRIES) { |
| return parseInt(process.env.CLAUDE_CODE_MAX_RETRIES, 10) |
| } |
| return DEFAULT_MAX_RETRIES |
| } |
| function getMaxRetries(options: RetryOptions): number { |
| return options.maxRetries ?? getDefaultMaxRetries() |
| } |
|
|
| const DEFAULT_FAST_MODE_FALLBACK_HOLD_MS = 30 * 60 * 1000 |
| const SHORT_RETRY_THRESHOLD_MS = 20 * 1000 |
| const MIN_COOLDOWN_MS = 10 * 60 * 1000 |
|
|
| function getRetryAfterMs(error: APIError): number | null { |
| const retryAfter = getRetryAfter(error) |
| if (retryAfter) { |
| const seconds = parseInt(retryAfter, 10) |
| if (!isNaN(seconds)) { |
| return seconds * 1000 |
| } |
| } |
| return null |
| } |
|
|
| function getRateLimitResetDelayMs(error: APIError): number | null { |
| const resetHeader = error.headers?.get?.('anthropic-ratelimit-unified-reset') |
| if (!resetHeader) return null |
| const resetUnixSec = Number(resetHeader) |
| if (!Number.isFinite(resetUnixSec)) return null |
| const delayMs = resetUnixSec * 1000 - Date.now() |
| if (delayMs <= 0) return null |
| return Math.min(delayMs, PERSISTENT_RESET_CAP_MS) |
| } |
|
|