Skip to content
Console →
Website →
Asking an AI? Paste this URL https://keelson.dev/llms.txt

Background tasks

Run slow work that follows a request, such as generating a PDF or making a heavy external API call, on an instance separate from the web app. This is called a “background task” (or “task” on this page).

Declare a command under tasks in keelson.yaml, then enqueue it from your app’s code with the Keelson SDK enqueue function. Keelson starts the command once and retries it automatically if it fails.

Tell your AI agent what should continue after the request, and it can write the tasks declaration and the code that enqueues it.

When an order is placed, generate a receipt PDF and email it as a background task. Make sure running the same task twice does not create duplicate PDFs or emails.

WorkWhat to use
Short work that finishes within the request, such as saving a formComplete it while handling the request
Work that runs at set times, such as a morning reportScheduled jobs (crons)
Work that a request starts and that runs afterwardBackground tasks (tasks)

Tasks suit work such as:

  • Work that does not fit within the HTTP request limit (120 seconds)
  • Heavy external API calls (AI generation, sending large amounts of data, and so on)
  • Generating PDFs or images, or sending bulk email

Work that continues inside the app after the response is sent (such as FastAPI’s BackgroundTasks) is not guaranteed to finish. Move that work to a task.

Write the task name and the command to run under tasks in keelson.yaml.

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql
tasks:
- name: generate-pdf
command: "python generate_pdf.py"
timeout: 180 # limit each attempt to 180 seconds (valid on every plan)
max_attempts: 3 # try up to 3 times, including the first attempt
FieldMeaning
nameTask name, using lowercase letters, digits, and hyphens. You pass this name when enqueueing
commandCommand that performs one run of the work and exits
timeoutTime limit for one attempt, in seconds. Defaults to 300 seconds (or your plan’s limit, if lower). A value above your plan’s limit is rejected at deploy
max_attemptsMaximum number of attempts, including the first. Defaults to 3

name and command are required. See the keelson.yaml reference for allowed ranges and validation rules.

From your app’s code, enqueue a task by its declared name with input data. The call returns right away with the task ID (task_id). See Keelson SDK for installation.

from keelson import tasks
task_id = tasks.enqueue("generate-pdf", {"order_id": 1},
idempotency_key="order-1-pdf")
status = tasks.get(task_id)
print(status.status, status.claimed_attempts, status.last_failure_code)
try:
tasks.enqueue("generate-pdf", {"order_id": 2})
except tasks.TasksError as e:
if e.code == "TASK_MONTHLY_QUOTA_EXCEEDED":
... # this month's limit is used up

When you pass an idempotency_key, enqueueing again with the same task name and key does not create a new task; it returns the first task’s ID. A key is 1–128 printable ASCII characters.

get returns one of five statuses (status):

StatusMeaning
queuedWaiting. Includes waiting to start and waiting to retry
runningRunning
succeededSucceeded (exit code 0)
failedFailed. last_failure_code gives the reason (When a task fails)
cancelledCancelled before it started

claimed_attempts is the number of attempts started, and last_failure_code is the reason the last failed attempt failed.

To show completion in your UI, have the task command write its result to the database and have the UI read a status column there. Use get as a supplementary check.

A task command is an ordinary program. When Keelson starts the command, it writes one line of JSON to standard input and then closes it:

{"attempt_no":1,"payload":{"order_id":1},"schema_version":1,"task_id":"…","task_name":"generate-pdf"}
KeyContents
attempt_noWhich attempt this is (starting at 1)
payloadInput data passed at enqueue time (null if omitted)
schema_versionVersion of this input format. Currently 1
task_idTask ID. Stays the same across retries
task_nameTask name

The attempt succeeds if the command exits with exit code 0; any other result is a failure.

import json
import sys
doc = json.loads(sys.stdin.readline())
order_id = doc["payload"]["order_id"]
# ... generate and save the PDF ...
# An uncaught exception exits non-zero and counts as a failure

Tasks do not share a local disk with the web app. Save results to the database or with the Files / Media SDK. The command can read the same environment variables as a scheduled job.

Tasks run at least once. Failed attempts are retried automatically, and even after a successful attempt, the command can run again if its completion does not reach Keelson. Writing the command so that running it more than once does not corrupt the result (making it idempotent) is your app’s responsibility.

Follow these three rules:

  1. Compare state before changing it: do “mark as processing if still pending” in a single UPDATE. If no rows were updated, another attempt is already handling it, so exit.

    UPDATE orders SET pdf_status = 'processing'
    WHERE id = ? AND pdf_status = 'pending';
  2. Pass an idempotency key to external APIs: for external APIs where receiving the same request twice is a problem, such as payments or email, pass an idempotency key. The task_id works, because it stays the same across retries.

  3. Use fixed output file names and row keys: save under names derived from the input, such as receipts/order-1.pdf. Names that include timestamps or random values produce two outputs when the command runs twice.

If you want “every saved order also enqueues a receipt task,” you cannot put saving the order and enqueueing the task in one transaction: the order is stored in your app’s database, while the task is recorded on Keelson. If the app stops right after saving, the order remains but the task is never enqueued.

To prevent this, write a row for “a task not yet enqueued” (an outbox row) to your app’s database in the same transaction as the order, then enqueue after the commit. This is commonly called the outbox pattern.

  1. In one transaction, write the order row and an outbox row (ID, task name, input, and an enqueued flag)
  2. After the commit, call enqueue with the outbox row’s ID as the idempotency_key
  3. When the enqueue succeeds, mark the outbox row as enqueued
  4. Rows whose enqueue failed, or whose app stopped before marking them, are sent again with the same key on the next request or by a scheduled job. Because the key is the same, the task is never enqueued twice
Web (request handling) App database Keelson (tasks)
│ ① Write the order row + outbox row in one transaction
│ ─────────────────────────────▶ │
│ ② enqueue(name, input, idempotency_key = outbox row ID)
│ ────────────────────────────────────────────────────────────▶ │
│ ◀──────────────────────────────────────────────── task_id ──── │
│ ③ Mark the outbox row as enqueued
│ ─────────────────────────────▶ │
④ Rows that failed or stopped before ②③ are resent with the same key by the next request or a cron (no duplicates)
import json
import uuid
from keelson import tasks
def place_order(conn, order_id, items):
outbox_id = str(uuid.uuid4())
conn.execute("INSERT INTO orders (id, items) VALUES (?, ?)", (order_id, items))
conn.execute(
"INSERT INTO task_outbox (id, task_name, payload, enqueued) VALUES (?, ?, ?, 0)",
(outbox_id, "send-receipt", json.dumps({"order_id": order_id})),
)
conn.commit()
flush_outbox(conn)
def flush_outbox(conn):
# Also call this from later requests or a scheduled job
rows = conn.execute(
"SELECT id, task_name, payload FROM task_outbox WHERE enqueued = 0"
).fetchall()
for outbox_id, name, payload in rows:
tasks.enqueue(name, json.loads(payload), idempotency_key=outbox_id)
conn.execute("UPDATE task_outbox SET enqueued = 1 WHERE id = ?", (outbox_id,))
conn.commit()

Automatic retries: when the command ran and failed (exit_nonzero, timed_out, or no_completion) and the attempt count has not reached max_attempts, Keelson retries after waiting 10, 20, 40, and then 80 seconds. You cannot change these intervals.

When the task ends before the command runs (delivery_failed, manifest_unresolvable, or quota_exhausted), it is not retried and becomes failed immediately.

last_failure_codeMeaning
exit_nonzeroThe command exited with a non-zero exit code
timed_outThe command exceeded its time limit (timeout) and was stopped
no_completionThe deadline passed without a completion report
delivery_failedThe attempt could not start within one hour of becoming ready to run
manifest_unresolvableTwo or more deploys happened while the attempt was waiting, so the declaration it needed could not be resolved
quota_exhaustedThe monthly limit could not be reserved for a retry, or for an attempt starting in a new month

Investigate: on the app’s Background Tasks tab in the console, you can see each task’s status and the standard error of its last attempt (up to 256 KiB, with secret values masked). get in the SDK does not return standard error.

Try again: there is no manual re-run. To try again, enqueue the task again from your app. If you use an idempotency key, use a different key from the original task (the same key returns the original task).

When tasks are cancelled: the following actions change the affected queued tasks to cancelled. An attempt that is already running finishes, but it is not retried if it fails.

  • Manually suspending the app
  • Quarantining the app for abuse
  • Deleting the app (task records are deleted too)
  • A deploy that removes the name from tasks (only tasks with the removed name)

You can also cancel a specific queued task, one at a time, from the console’s Background Tasks tab. An app sleeping because it has no traffic does not cancel tasks.

ItemLimit
Definitions10 per app
Unprocessed (queued) tasks1,000 per app. Beyond that, enqueueing is rejected with TASK_BACKLOG_LIMIT_EXCEEDED
Run time of one attempt300 seconds by default, 600 seconds maximum. Plan limits: Starter 180 seconds / Plus 300 seconds / Team and above 600 seconds
Attempts3 by default, 5 maximum
Input size65,536 bytes for the whole enqueue request (payload and idempotency_key as JSON). Larger requests fail with TASK_PAYLOAD_TOO_LARGE
Input retentionDeleted 7 days after the task finishes
Monthly limitThe run count and total run time of “Background Jobs,” shared with scheduled jobs

If the default timeout (300 seconds) exceeds your plan’s limit, it is lowered to the limit. Explicitly setting a value above the limit causes the deploy to be rejected.

For the monthly limit, each started attempt counts as one run, including retries. Once the limit is reached, new tasks are rejected with TASK_MONTHLY_QUOTA_EXCEEDED. Enqueueing again with the same idempotency key still returns the first task, even at the limit.

Each plan limits how many tasks run at the same time; tasks beyond that wait their turn. See Plans and limits for the monthly limit values.

You can enqueue tasks not only from web request handling but also from scheduled jobs and from task commands. For example, a nightly scheduled job can find the records to process and enqueue one task per record.

Keelson handles authentication automatically. You do not need to put tokens in your code.

Outside Keelson (when KEELSON_MODE=local is set, or when no Keelson environment variables are present), the SDK’s enqueue starts the CLI’s keelson dev task run as a child process and returns after the command finishes (synchronous execution).

Local runs differ from production as follows:

  • enqueue does not return until the work finishes
  • The command runs as a child process on the same development machine, not on a separate instance. It can see local files that it would not see in production
  • There are no retries. If the command fails, enqueue does not raise an error; get returns failed
  • There is no concurrency limit, unprocessed task limit, or monthly limit
  • Run time is not lowered to a plan limit; the declared timeout is used as is
  • An idempotency key prevents duplicates only for sequential enqueues within the same process. It does not prevent them for concurrent enqueues, across processes, or after a restart
  • get finds only tasks enqueued within the same process

Passing locally does not guarantee the same result in production, where retries and plan limits apply and an attempt can run more than once.

Running locally requires a CLI version that includes keelson dev task run. With an older CLI, enqueue fails with TASKS_LOCAL_CLI_FAILED; update with keelson upgrade.

You can also test just the command without going through the app:

Terminal window
keelson dev task run generate-pdf --payload '{"order_id": 1}'