errand
Stateful background jobs — the missing middle ground between FastAPI's
BackgroundTasksand Celery. A zero-dependency engine with an optional, first-class FastAPI adapter.
BackgroundTasks is fire-and-forget: you can't tell whether a task started,
is running, finished, or failed. The next step up is Celery/ARQ + Redis — a
broker, a worker runtime, and a pile of ops. errand fills the valley in
between: in-process jobs with tracked state, retries, scheduling,
and dependency injection inside tasks — using nothing but the Python
standard library.
The engine imports zero third-party packages. FastAPI is an optional extra:
install it and you get a drop-in lifespan and a status router; skip it and the
engine still runs anywhere. Because FastAPI is user-supplied and only its most
stable public surface is touched (APIRouter, the .dependency attribute on
Depends), a FastAPI release won't leave errand stranded.
Status: 0.1.1, published. Job store, worker pool, status router, retries with backoff, dependency injection, and scheduling are all implemented, tested (100% coverage), and live on PyPI — see
CHANGELOG.md.The PyPI name
errandturned out to be taken by an unrelated, actively maintained package, so this project publishes aserrand-jobs(pip install errand-jobs) while the import name stayserrand_jobs— the GitHub repo and project name remainerrand.
Why
- State you can query. Every job has an id and a status
(
PENDING → RUNNING → SUCCEEDED / FAILED), with timestamps, result, and error captured. An optional router exposes it over HTTP out of the box. - No new infrastructure. Pure
asyncio. Runs inside your app process (your FastAPI app, or any async program). Start with the in-memory store; swap in a durable store later without touching your task code. - Retries with backoff. Fixed or exponential, configured per task.
- Scheduling built in.
interval,at, and a cron subset — no separate beat process. - Real dependency injection. Use the same
Depends(...)callables you use in routes, includingyield-based resources with proper teardown. - Sync tasks don't block the loop. Plain
deftasks run in a thread.
Non-goals
Not a distributed task queue. If you need multi-machine workers, guaranteed
delivery across a broker, or millions of jobs, use Celery/ARQ. errand
targets the single-process, "I just need to know if it worked" case that most
apps actually have.
Install
pip install errand-jobs # engine only, zero dependencies
pip install "errand-jobs[fastapi]" # + the lifespan and status router
# or
uv add "errand-jobs[fastapi]"
Requires Python 3.10+. FastAPI is only needed for the adapter (the .router);
the engine runs standalone. The distribution is errand-jobs; the import
name is errand_jobs.
Quickstart
from fastapi import FastAPI
from errand_jobs import Errand
tasks = Errand() # in-memory store, 4 workers
app = FastAPI(lifespan=tasks.lifespan) # starts/drains workers + scheduler
app.include_router(tasks.router, prefix="/jobs") # optional status API
@tasks.task(max_retries=3, retry_backoff="exponential")
async def send_welcome_email(user_id: int) -> None:
... # slow work
@app.post("/signup")
async def signup() -> dict:
job = tasks.enqueue(send_welcome_email, user_id=42)
return {"job_id": job.id}
Check on it:
GET /jobs/{job_id}
→ {"id": "...", "name": "send_welcome_email", "status": "RUNNING",
"attempts": 1, "created_at": "...", "started_at": "...", ...}
Note:
enqueue()returns immediately with aPENDINGjob, but persisting that record happens on the next tick of the event loop. If you callget_job()(or hit the status endpoint) immediately afterward with noawaitin between, you can getNone/404 for an instant. Poll tolerantly rather than asserting the record exists on the first check — see the quickstart tests in the repo for the pattern.
Dependency injection in tasks
The same pattern you use in routes, teardown included:
from fastapi import Depends
from errand_jobs import Errand
tasks = Errand()
async def get_db():
db = Session()
try:
yield db
finally:
db.close()
@tasks.task
async def reindex(db=Depends(get_db)) -> None:
... # db is torn down after the task, success or failure
Scheduling
@tasks.schedule(cron="0 * * * *") # hourly
async def hourly_cleanup() -> None:
...
@tasks.schedule(interval_seconds=30) # every 30s
async def heartbeat() -> None:
...
Scheduled runs are tracked exactly like enqueued jobs.
Using errand with sync frameworks (Flask, Django)
FastAPI gets first-class treatment: pass tasks.lifespan to FastAPI(...)
and the worker pool starts and drains with the app, no glue code needed.
Flask and Django are WSGI-based and don't own an event loop the way an ASGI
app does, so errand's asyncio engine needs a small bridge — run one
event loop in a background thread for the app's lifetime, and hop onto it
from each view with asyncio.run_coroutine_threadsafe(...):
import asyncio
import threading
from errand_jobs import Errand
tasks = Errand()
@tasks.task
def resize_image(path: str) -> str:
... # slow work
_loop = asyncio.new_event_loop()
threading.Thread(target=_loop.run_forever, daemon=True).start()
asyncio.run_coroutine_threadsafe(tasks.startup(), _loop).result()
enqueue() schedules an internal task on the running loop, so it must be
called from a coroutine running on _loop — wrap it rather than calling
tasks.enqueue(...) straight from the view:
# Flask
@app.post("/upload")
def upload():
async def _enqueue():
return tasks.enqueue(resize_image, request.form["path"])
job = asyncio.run_coroutine_threadsafe(_enqueue(), _loop).result()
return {"job_id": job.id}
# Django
def upload_view(request):
async def _enqueue():
return tasks.enqueue(resize_image, request.POST["path"])
job = asyncio.run_coroutine_threadsafe(_enqueue(), _loop).result()
return JsonResponse({"job_id": job.id})
get_job()/list_jobs() are already coroutines, so the same call works
directly with no wrapper:
job = asyncio.run_coroutine_threadsafe(tasks.get_job(job_id), _loop).result()
On process exit (e.g. via atexit), drain in-flight jobs the same way:
asyncio.run_coroutine_threadsafe(tasks.shutdown(), _loop).result()
Backends
The core ships with InMemoryJobStore. The JobStore interface is the single
seam for durability — a Redis or Postgres store can be added later as an
optional extra without changing any task code.
Roadmap
All of TASKS.md's
milestones (M0–M7) are implemented, tested, and merged — this is a complete
0.1.0, not a work in progress. TASKS.md is kept as the historical build
plan; contributor and architecture notes live in
DESIGN.md
and CLAUDE.md;
release notes are in
CHANGELOG.md.
Explicitly deferred past 0.1.0 (called out as such in DESIGN.md): a
durable JobStore (Redis/Postgres — the interface is already the seam for
it), remote enqueue/cancel over HTTP, and jitter on retry backoff.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file errand_jobs-0.1.1.tar.gz.
File metadata
- Download URL: errand_jobs-0.1.1.tar.gz
- Upload date:
- Size: 786.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b7df22cf4f8f3d193eafe6138b696cdff2a815a2b2fc94a556cae5860475d522
|
|
| MD5 |
a71a9d3c585a5b1b2bd6666e2628e2a3
|
|
| BLAKE2b-256 |
c6980236a2b6404f650ac96279258bcf176ace5de4b850f091bf142e9968fa38
|
Provenance
The following attestation bundles were made for errand_jobs-0.1.1.tar.gz:
Publisher:
release.yml on jmiguelmangas/errand
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
errand_jobs-0.1.1.tar.gz -
Subject digest:
b7df22cf4f8f3d193eafe6138b696cdff2a815a2b2fc94a556cae5860475d522 - Sigstore transparency entry: 2474975844
- Sigstore integration time:
-
Permalink:
jmiguelmangas/errand@758b0279d11a449dfa3839a401ece29d6af8d4a3 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/jmiguelmangas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@758b0279d11a449dfa3839a401ece29d6af8d4a3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file errand_jobs-0.1.1-py3-none-any.whl.
File metadata
- Download URL: errand_jobs-0.1.1-py3-none-any.whl
- Upload date:
- Size: 21.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4b951956a3162e2152aa2ba4d86e89a00c8bcb25720564164122c2351331de4f
|
|
| MD5 |
a6dbd1c70ce2940c98367a2c720a6af6
|
|
| BLAKE2b-256 |
1b6dde284c7a19d492a3ab017cf4656dcc75a2e7525f657d82922395b0730ed8
|
Provenance
The following attestation bundles were made for errand_jobs-0.1.1-py3-none-any.whl:
Publisher:
release.yml on jmiguelmangas/errand
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
errand_jobs-0.1.1-py3-none-any.whl -
Subject digest:
4b951956a3162e2152aa2ba4d86e89a00c8bcb25720564164122c2351331de4f - Sigstore transparency entry: 2474976048
- Sigstore integration time:
-
Permalink:
jmiguelmangas/errand@758b0279d11a449dfa3839a401ece29d6af8d4a3 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/jmiguelmangas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@758b0279d11a449dfa3839a401ece29d6af8d4a3 -
Trigger Event:
push
-
Statement type: