Google Cloud Scheduler: Retries, Deadlines, and Monitoring
Contents
Google Cloud Scheduler is Google Cloud's managed cron: each job pairs a five-field unix-cron schedule and a timeZone, UTC unless you set one, with a single target (an HTTP endpoint, a Pub/Sub topic, or an App Engine app) and calls that target at each scheduled time. It retries a failed call only when you configure retryConfig, waits at most the attemptDeadline for an HTTP target to answer (3 minutes by default, 30 at most), and delivers at least once, so one occurrence can reach the target twice. A successful call means only that the target accepted the request: for a Cloud Run job, that the execution was created, not that it finished. Google's documentation behind this guide was checked October 25, 2026.
Anatomy of a Cloud Scheduler Job#
| Field | What it does | Default |
|---|---|---|
schedule |
Unix-cron, or the English-like "groc" format such as every 3 hours |
Required |
timeZone |
A tz database name used to read schedule |
UTC (Etc/UTC in gcloud) |
httpTarget, pubsubTarget, or appEngineHttpTarget |
Exactly one target | None |
attemptDeadline |
How long Scheduler waits for an HTTP or App Engine response | 3 minutes for HTTP |
retryConfig |
Retries of a failed attempt | No retries |
state |
ENABLED, PAUSED, DISABLED, or UPDATE_FAILED |
ENABLED |
Fields are from the Job resource and gcloud reference. The cron dialect is close to crontab's (cron format): 0 or 7 is Sunday, there is no L, and when both day fields are restricted, a date matching either runs the job, so 0 0 1 * SAT fires on the 1st and every Saturday.
Pricing, checked October 25, 2026: $0.10 per job per 31 days, 3 free jobs per billing account per month, and a paused job still counts (pricing). A project can hold 5,000 jobs per region by default (quotas).
The Attempt Deadline and What Counts as Success#
| Target | Success | attemptDeadline |
|---|---|---|
| HTTP: Cloud Run services and functions, or any reachable URL | A 2xx response; "a failure to receive a response constitutes a failed execution" | 3 minutes by default; 15 seconds to 30 minutes |
| App Engine HTTP | A 2xx response; a 503 is "retried regardless of retry configuration and not counted against retry counts" | By scaling type: 10 minutes (standard, automatic), 24 hours (standard, manual or basic), 60 minutes (flexible); if set, 15 seconds to 24 hours 15 seconds |
| Pub/Sub | The message is published; the subscriber's work is outside Scheduler's view | Ignored |
A Missed Deadline Doesn't Stop the Target#
When the handler doesn't answer in time, "the request is cancelled and the attempt is marked as a DEADLINE_EXCEEDED failure," and Scheduler retries according to retryConfig (Job). Cancelling the request doesn't cancel the work: when a connection closes, Cloud Run says the container "might continue processing the aborted request" (request timeout), and Scheduler's troubleshooting guide notes that "Cloud Scheduler times out but the destination service doesn't."
So a handler that needs 4 minutes under the default 3-minute deadline is logged as failed and, if retries are on, called again while the first attempt is still working: two runs for one occurrence, and a logged failure for a run that succeeded (manage jobs). Set attemptDeadline well above the handler's worst run time, give the handler its own time budget below that, and move work that can run past 30 minutes to a Cloud Run job.
One Outstanding Attempt per Job#
"Cloud Scheduler will never allow two simultaneously outstanding executions" (Job). While an attempt waits on its target, the next occurrence doesn't start: the API reference says it "will be delayed," the manage jobs page that other runs "are skipped until the current run finishes or times out." Retries hold it back too: "the next scheduled execution time might be skipped if the retries continue through that time" (retry jobs). Either way, a slow target makes later runs late or missing.
Retry Settings in retryConfig#
| API field | gcloud flag | Default | Meaning |
|---|---|---|---|
retryCount |
--max-retry-attempts |
0 | Retries of a failed attempt, 0 to 5 |
maxRetryDuration |
--max-retry-duration |
0 (no limit) | Time limit, measured from the first attempt |
minBackoffDuration |
--min-backoff |
5 seconds | First wait |
maxBackoffDuration |
--max-backoff |
3,600 seconds | Longest wait |
maxDoublings |
--max-doublings |
5 | Times the wait doubles before growing linearly |
With a 10-second minimum, a 300-second maximum, and 3 doublings, retries come 10, 20, 40, 80, and 160 seconds apart. retryCount alone retries exactly that many times; with maxRetryDuration also set, the job retries "until both limits are reached" and "could retry more times than the retryCount" (retry jobs).
Any response outside 200-299, and any missed deadline, is a failure (HttpTarget), so a target that fails halfway and gets retried must be safe to run again. Retrying failed cron jobs covers choosing which layer retries, and missed runs after downtime compares Scheduler with schedulers that replay missed runs.
At-Least-Once Delivery and the Schedule-Time Header#
Scheduler "is designed to provide 'at least once' delivery." "In some rare circumstances, it is possible for a job to run multiple times in association with a single instance of the schedule," so "your targets should be idempotent" (overview). Each HTTP and App Engine request carries X-CloudScheduler: true, the job's name in X-CloudScheduler-JobName, and, for unix-cron schedules, the occurrence's scheduled time in RFC 3339 in X-CloudScheduler-ScheduleTime, which "remains constant across retries and can be used for job request deduplication" (HttpTarget).
Claim the job name plus schedule time before doing the work, as idempotent scheduled jobs shows with a runs table. A Cloud Run job's container never sees these headers, because they go to the Admin API call that starts it, so key its work on the period it covers, such as the export's date.
Authenticating to Cloud Run: OIDC or OAuth#
Scheduler attaches a token for a service account in the job's project (HTTP target auth):
- OIDC for Cloud Run services, Cloud Run functions, and endpoints that verify the token themselves. Cloud Run expects the
audclaim to be "the URL of the receiving service or a configured custom audience" (service-to-service), while Scheduler defaults it to the whole target URL, query string included, so set it. - OAuth for Google APIs on
*.googleapis.com, including the Cloud Run Admin API that runs jobs.
Grant the account roles/run.invoker on the service or job; whoever creates the Scheduler job needs Service Account User on it. Don't use the Cloud Scheduler service agent as the job's account, and don't revoke the agent's roles/cloudscheduler.serviceAgent role, or calls get 403 "even if your job's service account has the appropriate role." For a Cloud Run service:
gcloud iam service-accounts create scheduler-invoker --project=acme-prod
gcloud run services add-iam-policy-binding report-builder \
--region=us-central1 \
--member=serviceAccount:[email protected] \
--role=roles/run.invoker
gcloud scheduler jobs create http nightly-report \
--location=us-central1 \
--schedule="0 3 * * *" \
--time-zone=Etc/UTC \
--uri=https://report-builder-123456789012.us-central1.run.app/tasks/nightly-report \
--http-method=post \
--attempt-deadline=600s \
--max-retry-attempts=2 \
--min-backoff=60s \
--oidc-service-account-email=scheduler-invoker@acme-prod.iam.gserviceaccount.com \
--oidc-token-audience=https://report-builder-123456789012.us-central1.run.appThe 600-second deadline sits well above a report that takes at most 4 minutes. The service's own request timeout, 5 minutes by default, must exceed the run time too, or Cloud Run answers 504 and the same overlapping retry follows. A failed attempt is retried 60 and then 120 seconds later, with the same X-CloudScheduler-ScheduleTime.
Running a Cloud Run Job on a Schedule#
A Cloud Run job doesn't serve requests, so Scheduler starts it through the Admin API's jobs.run method with an OAuth token (execute jobs on a schedule). For the job deployed further down:
gcloud run jobs add-iam-policy-binding invoice-export \
--region=us-central1 \
--member=serviceAccount:[email protected] \
--role=roles/run.invoker
gcloud scheduler jobs create http invoice-export-nightly \
--location=us-central1 \
--schedule="30 2 * * *" \
--time-zone=Etc/UTC \
--uri=https://run.googleapis.com/v2/projects/acme-prod/locations/us-central1/jobs/invoice-export:run \
--http-method=post \
--max-retry-attempts=3 \
--oauth-service-account-email=scheduler-invoker@acme-prod.iam.gserviceaccount.comjobs.run "Triggers creation of a new Execution of this Job" and returns a long-running Operation (jobs.run), so Scheduler's attempt succeeds once the execution exists. The execution then runs on Cloud Run's terms:
- Task timeout: 10 minutes by default, up to 168 hours, applied to each attempt (task timeout). At the timeout, the task gets
SIGTERM, thenSIGKILL10 seconds later (container contract). - Retries: a failed task restarts up to
--max-retriestimes, 3 by default and 0 to 10, counted per task (max retries). - Result: the container exits 0 for success and non-zero for failure, and the execution succeeds only if all of its tasks do (execute jobs).
- Identity:
CLOUD_RUN_EXECUTION,CLOUD_RUN_TASK_INDEX, andCLOUD_RUN_TASK_ATTEMPT, which counts retries from 0 (job variables).
None of this reaches Scheduler, which records a successful attempt even if every task then fails. Its deadline, retries, and one-attempt-at-a-time rule cover only the API call, so it doesn't hold the next call back while an execution is still running. And every jobs.run creates a new execution, so a duplicate delivery or a retried call that had in fact gone through starts a second one. On the Cloud Run side, an alerting policy can watch run.googleapis.com/job/completed_execution_count, which counts finished executions by result (metrics).
Why a Cloud Scheduler Job Isn't Running#
Start with gcloud scheduler jobs describe invoice-export-nightly --location=us-central1. It shows state; scheduleTime, the next run, which "may be a retry of a previously failed attempt"; lastAttemptTime; and status, the target's response to the last attempt.
| Cause | What to check |
|---|---|
| Paused | state: PAUSED. A job edited while paused "remains paused until you resume it" (manage jobs): gcloud scheduler jobs resume invoice-export-nightly --location=us-central1 |
| Disabled or a failed update | state: DISABLED is set by the system after an error; UPDATE_FAILED clears when an update succeeds |
| Cloud Scheduler API disabled | The audit-log query in troubleshooting. After you re-enable it, "any job that failed to run in the interim runs immediately" (cron format) |
| The previous attempt never ended | An AttemptStarted log entry with no AttemptFinished, or one that lands after the next scheduled time, blocks later runs |
| Every attempt fails | The execution log's error: URL_ERROR-ERROR_AUTHENTICATION (401 or 407), URL_ERROR-ERROR_OTHER with code 403 (roles or the service agent), URL_ERROR-ERROR_NOT_FOUND (404), URL_UNREACHABLE-UNREACHABLE_5xx (5xx or 429) |
Scheduler logs the start and end of each execution (view logs) as type.googleapis.com/google.cloud.scheduler.logging.AttemptStarted and AttemptFinished entries (troubleshooting), under the cloud_scheduler_job resource with project_id, location, and job_id labels (resource list). In Logs Explorer:
resource.type="cloud_scheduler_job"
resource.labels.job_id="invoice-export-nightly"A job on the wrong day usually restricts both day fields; one at the wrong hour usually has a local timeZone. Google says only that a job at a time that occurs twice "might observe execution anomalies" and that UTC "is recommended for Cloud Scheduler to avoid the problem completely" (time zone); nothing covers the skipped spring hour. Cron daylight saving time compares other schedulers.
Monitoring Scheduled Jobs on Google Cloud#
The native signals cover attempts that happen: a log-based alerting policy on Scheduler's execution entries, and the Cloud Run metric above. Google's Cloud Monitoring metrics list has no Cloud Scheduler entries (metrics, checked October 25, 2026), so for Scheduler itself, logs are what you get. None of this catches a job that stopped firing: a paused, deleted, or disabled job, or a disabled API, writes no entries, and a log alert can't fire on absence. Nor does it catch a run held back behind a slow attempt, or a task that hangs until its timeout every night.
That takes a dead man's switch: each run reports to a monitor outside Google Cloud that knows the schedule, and a missing report is the alert. EventBridge Scheduler has the same gap on AWS.
Report Each Run From the Job's Entrypoint#
There is no CronRadar SDK for Cloud Scheduler or Cloud Run and no auto-discovery, and CronRadar's Python SDK isn't available right now, so each job reports to its own monitor with HTTP check-ins. This entrypoint uses only the standard library:
# main.py: entrypoint of the Cloud Run job "invoice-export".
import base64
import os
import signal
import sys
import urllib.parse
import urllib.request
from invoices import export_invoices # the job's own work, shipped in the same image
PING_URL = "https://cron.life/api/v1/ping/invoice-export"
SCHEDULE = "30 2 * * *" # the Cloud Scheduler job's schedule, in UTC
GRACE_SECONDS = 900 # from the scheduled minute to the first start check-in
EXECUTION = os.environ.get("CLOUD_RUN_EXECUTION", "local")
TASK_INDEX = os.environ.get("CLOUD_RUN_TASK_INDEX", "0")
ATTEMPT = int(os.environ.get("CLOUD_RUN_TASK_ATTEMPT", "0")) # 0 on the first try
MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "3")) # keep equal to --max-retries
RUN_ID = f"{EXECUTION}-{TASK_INDEX}" # shared by every attempt of this task
class TaskTimeout(BaseException):
"""Raised on SIGTERM. A BaseException, so report() can't swallow it."""
def on_sigterm(signum, frame):
raise TaskTimeout("SIGTERM: task timeout or cancellation")
def report(kind, message=None):
"""Send one CronRadar check-in. Gives up after 5 seconds and never raises."""
try:
params = {
"schedule": SCHEDULE,
"gracePeriod": GRACE_SECONDS,
"run_id": RUN_ID,
"attempt": ATTEMPT,
}
if message:
params["message"] = message[:1000]
query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
request = urllib.request.Request(
f"{PING_URL}/{kind}?{query}",
headers={"Authorization": f"Basic {token}"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5):
pass
except Exception as error: # a monitoring problem must never fail the job
print(f"CronRadar {kind} check-in failed: {error!r}", file=sys.stderr)
def main():
signal.signal(signal.SIGTERM, on_sigterm)
report("start")
try:
export_invoices()
except (Exception, TaskTimeout) as error:
if ATTEMPT >= MAX_RETRIES: # Cloud Run won't retry this task again
report("fail", f"{type(error).__name__} in {EXECUTION}, attempt {ATTEMPT}")
raise
report("complete")
if __name__ == "__main__":
main()Build it with an exec-form ENTRYPOINT, so Python runs as PID 1 and receives SIGTERM itself; the shell form "does not pass signals" (Dockerfile reference):
FROM python:3.13-slim
WORKDIR /app
COPY . .
ENTRYPOINT ["python", "main.py"]gcloud run jobs create invoice-export \
--region=us-central1 \
--image=us-central1-docker.pkg.dev/acme-prod/jobs/invoice-export:1.4.0 \
--service-account=invoice-export@acme-prod.iam.gserviceaccount.com \
--tasks=1 \
--max-retries=3 \
--task-timeout=30m \
--set-env-vars=MAX_RETRIES=3 \
--set-secrets=CRONRADAR_API_KEY=cronradar-api-key:latest- Monitoring can't break the job. Each check-in gives up after 5 seconds, and its errors are logged and dropped. The job's exception is re-raised, so the task exits non-zero and Cloud Run retries it.
failnames only the exception type, since exception text can carry data that shouldn't leave your project. - Every attempt is labeled.
run_idis the execution plus the task index, shared by that task's retries, andCLOUD_RUN_TASK_ATTEMPTis zero-based, like CronRadar'sattempt. With--tasksabove 1, each task reports under its ownrun_id. - Only the last attempt sends
fail. Unlike most retried processes, a Cloud Run task can tell it's the last try: its attempt number equals the retry limit. Cloud Run doesn't pass that limit to the container, soMAX_RETRIESrepeats it; set both in one command. A retried failure raises no alert; the nextstartwith a higherattemptrecords it. To hear about the first failure instead, reportfailon every attempt and let a latercompleterecord the recovery. - A timeout becomes a
fail. At the task timeout, the handler raisesTaskTimeout, and the last attempt reportsfailbeforeSIGKILL. Python runs signal handlers between bytecode instructions, so a long call inside C code may not see the signal in time (signal). A task killed outright sends nothing. - Stuck detection is the backstop. CronRadar flags a run as stuck when its
startis still unanswered at the job's next expected run plus the grace period, and every check-in, including the next run'sstart, moves that time forward. A nightly hang is caught only when the job then goes silent, so the task timeout is what bounds each run. - The key comes from Secret Manager. The job's service account needs
roles/secretmanager.secretAccessoron the secret (job secrets). Use one CronRadar application and key per environment.
HTTP Targets: Use the Schedule Time as run_id#
A Cloud Run service or function that Scheduler calls directly reports the same way from its request handler, with three changes. Use X-CloudScheduler-ScheduleTime as run_id, generating one if the header is missing; Google documents it only for unix-cron schedules. Leave out attempt, since Scheduler sends no attempt number. And report fail on every failed attempt, because the handler can't know whether Scheduler will retry. A retry after a missed deadline then shows up as a second start with the same run_id while the first attempt may still be running.
Give CronRadar the Same Schedule#
CronRadar reads five-field cron in UTC, with Sunday as 0. When both day fields are restricted, a date must match both.
| Cloud Scheduler | CronRadar schedule |
Why |
|---|---|---|
0 9 * * 7 |
0 9 * * 0 |
CronRadar rejects 7 for Sunday |
0 6 * * *, America/New_York |
0 10 * * *, grace of 3,600 + run time |
The winter run arrives an hour later in UTC |
0 9 1 * MON |
0 9 1 * * and 0 9 * * 1, one Scheduler job and monitor key each |
Scheduler runs on either day; CronRadar needs both |
every 2 hours (groc) |
Rewrite as unix-cron, such as 0 */2 * * * |
No five-field form, and the schedule-time header is documented only for unix-cron |
Because every check-in carries the schedule, the first creates the monitor and the next recreates a deleted one. schedule and gracePeriod apply only at creation; change an existing monitor in the dashboard. A monitor created by its first check-in can't flag a job that never runs, so create it in the dashboard first: it stays pending, and a never-executed alert fires if the first expected run plus grace passes with no report. After that, a missing run is warning once its expected time passes and critical, with an incident, once it's past grace and at least two minutes late. A fail alerts immediately. When you pause a Scheduler job on purpose, pause its monitor in the dashboard too, or the skipped runs alert.
Frequently Asked Questions#
Does Cloud Scheduler retry failed jobs?#
Not by default: retryCount is 0, so a failed attempt waits for the next scheduled time. With --max-retry-attempts (at most 5), it retries non-2xx responses and missed deadlines with exponential backoff, so make the target safe to run twice.
What is the attempt deadline in Cloud Scheduler?#
How long Scheduler waits for an HTTP target's response: 3 minutes by default, settable from 15 seconds to 30 minutes. A late response counts as a failure, but the target may keep running, so a retry can overlap the original attempt.
Can Cloud Scheduler run a job twice?#
Yes. Delivery is at least once, so a rare duplicate is possible, and retries after a missed deadline add more. Deduplicate HTTP targets on the job name plus X-CloudScheduler-ScheduleTime, which stays the same across retries.
How do I schedule a Cloud Run job?#
Create an HTTP Scheduler job that POSTs to the job's :run URL on run.googleapis.com with an OAuth token for a service account holding roles/run.invoker on the job. Scheduler's success means only that the execution was created, so have the job report its own result.
What time zone does Cloud Scheduler use?#
UTC, unless the job sets timeZone to a tz database name. Local zones follow daylight saving, which Google documents only as possible "execution anomalies," so it recommends UTC. CronRadar evaluates schedules in UTC too.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.