Skip to content

FastAPI example (async)

A minimal FastAPI app that schedules an add_numbers task per request and exposes a second endpoint to poll for the result. The result is a pydantic model, serialized with TaskResultSerializer + PydanticSerializer. Built on AsyncAlchemicalQueues / AsyncWorker.

Run it

cd examples/fastapi_app
uv run --with fastapi,uvicorn,aiosqlite uvicorn app:app

Then, in another terminal:

curl -X POST localhost:8000/add -H 'Content-Type: application/json' -d '{"a": 2, "b": 3}'
# {"task_id": 1}

curl localhost:8000/result/1
# {"status": "done", "sum": 5}

The worker that executes the task runs as a plain asyncio task on the app's own event loop, started from FastAPI's lifespan -- no background thread needed, purely to keep this example to a single command. See the async_plain_python example for the more common pattern of running the worker as a separate process.

app.py

"""FastAPI example: schedule a task from one endpoint, poll for its result
from another, with the task's result serialized as a pydantic model via
[`TaskResultSerializer`][alchemical_queues.tasks.TaskResultSerializer] +
[`PydanticSerializer`][alchemical_queues.PydanticSerializer].

Built on [`AsyncAlchemicalQueues`][alchemical_queues.aio.AsyncAlchemicalQueues]
/ [`AsyncWorker`][alchemical_queues.tasks.aio.AsyncWorker]: the worker
runs as a plain `asyncio` task on FastAPI's own event loop, started from the
lifespan, so `uvicorn app:app` alone is enough to see it work end to end --
no background thread needed, unlike the sync queue classes. In a real
deployment you would instead run the worker as its own process (see the
async_plain_python example) so it can be scaled independently of the web app.
"""

import asyncio
import os
from contextlib import asynccontextmanager
from datetime import timedelta
from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine

from alchemical_queues import PydanticSerializer
from alchemical_queues.aio import AsyncAlchemicalQueues
from alchemical_queues.tasks import TaskResultSerializer
from alchemical_queues.tasks.aio import AsyncWorker, async_task
from alchemical_queues.tasks.main import TaskInfo


class AddRequest(BaseModel):
    a: int
    b: int


class AddResult(BaseModel):
    sum: int


@async_task
async def add_numbers(taskinfo: TaskInfo, a: int, b: int) -> AddResult:
    return AddResult(sum=a + b)


def create_app(database_url: Union[str, None] = None) -> FastAPI:
    database_url = database_url or os.environ.get(
        "DATABASE_URL", "sqlite+aiosqlite:///fastapi_example.db"
    )
    engine = create_async_engine(database_url)
    queues = AsyncAlchemicalQueues(engine)

    task_queue = queues.get_task_queue(
        "add-queue",
        response_serializer=TaskResultSerializer(PydanticSerializer(AddResult)),
    )

    @asynccontextmanager
    async def lifespan(_: FastAPI):
        await queues.create_all()
        worker = AsyncWorker(task_queue, poll_every=timedelta(milliseconds=100))
        # AsyncWorker.work() runs forever; this task shares the app's own
        # event loop and is cancelled on shutdown rather than left to leak.
        worker_task = asyncio.create_task(worker.work())
        try:
            yield
        finally:
            worker_task.cancel()

    app = FastAPI(title="Alchemical Queues - async FastAPI example", lifespan=lifespan)

    @app.post("/add")
    async def schedule_add(req: AddRequest) -> dict:
        entry = await add_numbers(req.a, req.b).schedule(task_queue)
        return {"task_id": entry.entry_id}

    @app.get("/result/{task_id}")
    async def get_result(task_id: int) -> dict:
        queued = add_numbers.retrieve(task_queue, task_id)
        if not await queued.done():
            return {"status": "pending"}

        result = await queued.result()
        if isinstance(result, AddResult):
            return {"status": "done", "sum": result.sum}
        return {"status": "error", "error": str(result)}

    return app


app = create_app()