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.
When to use
Section titled “When to use”| Work | What to use |
|---|---|
| Short work that finishes within the request, such as saving a form | Complete it while handling the request |
| Work that runs at set times, such as a morning report | Scheduled jobs (crons) |
| Work that a request starts and that runs afterward | Background 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.
Declare a task
Section titled “Declare a task”Write the task name and the command to run under tasks in keelson.yaml.
slug: my-appruntime: python-slimcommand: "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| Field | Meaning |
|---|---|
name | Task name, using lowercase letters, digits, and hyphens. You pass this name when enqueueing |
command | Command that performs one run of the work and exits |
timeout | Time 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_attempts | Maximum 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.
Enqueue and check status
Section titled “Enqueue and check status”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 upimport { enqueue, get, TasksError } from "@keelsonhq/tasks";
const taskId = await enqueue("generate-pdf", { order_id: 1 }, { idempotencyKey: "order-1-pdf",});const status = await get(taskId);console.log(status.status, status.claimed_attempts, status.last_failure_code);
try { await enqueue("generate-pdf", { order_id: 2 });} catch (e) { if (e instanceof TasksError && e.code === "TASK_MONTHLY_QUOTA_EXCEEDED") { // this month's limit is used up }}import "github.com/keelsonhq/go-sdk/tasks"
client, err := tasks.New()if err != nil { return err}taskID, err := client.Enqueue(ctx, "generate-pdf", map[string]int{"order_id": 1}, tasks.WithIdempotencyKey("order-1-pdf"))if err != nil { var te *tasks.Error if errors.As(err, &te) && te.Code == "TASK_MONTHLY_QUOTA_EXCEEDED" { // this month's limit is used up } return err}status, err := client.Get(ctx, taskID)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):
| Status | Meaning |
|---|---|
queued | Waiting. Includes waiting to start and waiting to retry |
running | Running |
succeeded | Succeeded (exit code 0) |
failed | Failed. last_failure_code gives the reason (When a task fails) |
cancelled | Cancelled 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.
Write the command
Section titled “Write the command”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"}| Key | Contents |
|---|---|
attempt_no | Which attempt this is (starting at 1) |
payload | Input data passed at enqueue time (null if omitted) |
schema_version | Version of this input format. Currently 1 |
task_id | Task ID. Stays the same across retries |
task_name | Task name |
The attempt succeeds if the command exits with exit code 0; any other result is a failure.
import jsonimport 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 failureimport { readFileSync } from "node:fs";
const doc = JSON.parse(readFileSync(0, "utf8"));const orderId = doc.payload.order_id;// ... generate and save the PDF ...// An uncaught exception exits non-zero and counts as a failurevar doc struct { AttemptNo int `json:"attempt_no"` Payload json.RawMessage `json:"payload"` SchemaVersion int `json:"schema_version"` TaskID string `json:"task_id"` TaskName string `json:"task_name"`}if err := json.NewDecoder(os.Stdin).Decode(&doc); err != nil { log.Fatal(err) // exits with code 1 and counts as a failure}// ... generate and save the PDF ...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.
Make it safe to run twice
Section titled “Make it safe to run twice”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:
-
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'; -
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_idworks, because it stays the same across retries. -
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.
Always process a saved order
Section titled “Always process a saved order”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.
- In one transaction, write the order row and an outbox row (ID, task name, input, and an enqueued flag)
- After the commit, call
enqueuewith the outbox row’s ID as theidempotency_key - When the enqueue succeeds, mark the outbox row as enqueued
- 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 jsonimport uuidfrom 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()When a task fails
Section titled “When a task fails”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_code | Meaning |
|---|---|
exit_nonzero | The command exited with a non-zero exit code |
timed_out | The command exceeded its time limit (timeout) and was stopped |
no_completion | The deadline passed without a completion report |
delivery_failed | The attempt could not start within one hour of becoming ready to run |
manifest_unresolvable | Two or more deploys happened while the attempt was waiting, so the declaration it needed could not be resolved |
quota_exhausted | The 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.
Limits
Section titled “Limits”| Item | Limit |
|---|---|
| Definitions | 10 per app |
Unprocessed (queued) tasks | 1,000 per app. Beyond that, enqueueing is rejected with TASK_BACKLOG_LIMIT_EXCEEDED |
| Run time of one attempt | 300 seconds by default, 600 seconds maximum. Plan limits: Starter 180 seconds / Plus 300 seconds / Team and above 600 seconds |
| Attempts | 3 by default, 5 maximum |
| Input size | 65,536 bytes for the whole enqueue request (payload and idempotency_key as JSON). Larger requests fail with TASK_PAYLOAD_TOO_LARGE |
| Input retention | Deleted 7 days after the task finishes |
| Monthly limit | The 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.
Where you can enqueue from
Section titled “Where you can enqueue from”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.
Run locally
Section titled “Run locally”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:
enqueuedoes 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,
enqueuedoes not raise an error;getreturnsfailed - There is no concurrency limit, unprocessed task limit, or monthly limit
- Run time is not lowered to a plan limit; the declared
timeoutis 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
getfinds 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:
keelson dev task run generate-pdf --payload '{"order_id": 1}'