Skip to content

[Bug] Recurring cron jobs silently skip transient-error retry — only one-shot jobs retry with backoff #46341

Description

@onesmash

Summary

One-shot (at) jobs correctly detect transient errors (network, timeout, rate-limit, 5xx) and retry with exponential backoff. Recurring (cron / every) jobs have no such logic — on any error they simply wait until the next natural schedule time, which can be hours or days away.

Root Cause

In src/cron/service/timer.ts, the two code paths are asymmetric:

One-shot jobs — transient detection present ✅ (lines 379–396)

const retryConfig = resolveRetryConfig(state.deps.cronConfig);
const transient = isTransientCronError(result.error, retryConfig.retryOn);
if (transient && consecutive <= retryConfig.maxAttempts) {
  // Schedule retry with backoff
  job.state.nextRunAtMs = result.endedAt + backoff;
}

Recurring jobs — transient detection absent ❌ (lines 416–434)

const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
let normalNext: number | undefined;
// ...
const backoffNext = result.endedAt + backoff;
// Use whichever is later: the natural next run or the backoff delay.
job.state.nextRunAtMs =
  normalNext !== undefined ? Math.max(normalNext, backoffNext) : backoffNext;

Math.max(normalNext, backoffNext) picks whichever is later. For long-period jobs (hourly, daily) normalNext is always larger, so backoffNext is always ignored.

Impact

Daily job fails at 10:00 due to network jitter
  → Current:  next run at 10:00 tomorrow (24 hours later)
  → Expected: retry after 30s; if still failing, 1m → 5m → ... → back to schedule

Transient errors that would self-heal in seconds force a full schedule wait. The consecutiveErrors counter increments correctly but the retry shortcut never fires.

Suggested Fix

Apply the same transient-error check to recurring jobs:

} else if (result.status === "error" && job.enabled) {
  const retryConfig = resolveRetryConfig(state.deps.cronConfig);
  const transient = isTransientCronError(result.error, retryConfig.retryOn);
  const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
  let normalNext: number | undefined;
  try {
    normalNext = opts?.preserveSchedule && job.schedule.kind === "every"
      ? computeNextWithPreservedLastRun(result.endedAt)
      : computeJobNextRunAtMs(job, result.endedAt);
  } catch (err) {
    recordScheduleComputeError({ state, job, err });
  }
  const backoffNext = result.endedAt + backoff;

  if (transient) {
    // Transient error: retry soon with backoff, don't wait for next schedule
    job.state.nextRunAtMs = backoffNext;
  } else {
    // Permanent error: respect natural schedule (backoff guards short-interval jobs)
    job.state.nextRunAtMs = normalNext !== undefined
      ? Math.max(normalNext, backoffNext)
      : backoffNext;
  }
}

Transient patterns already defined (TRANSIENT_PATTERNS): rate_limit, network, timeout, server_error — no new infrastructure needed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions