Skip to main content

Trigger delayed Cloud Tasks from FastAPI

Project description

FastAPI Cloud Tasks

Strongly typed background tasks with FastAPI and Google Cloud Run, Tasks and Scheduler. This is a fork of fastapi-gcp-tasks, updated with new features and bug fixes.

sequenceDiagram
    autonumber
    actor User
    participant Service
    participant CloudTasks
    participant Worker


    User ->>+ Service: /trigger


    rect rgb(100,130,180)
    note right of Service: hello.delay()
    Service -->>+ CloudTasks: Create task
    CloudTasks -->>- Service: Accepted
    end

    Service ->>- User: Hello task triggered
    note right of CloudTasks: Async
    CloudTasks -->>+ Worker: /hello
    Worker -->>- CloudTasks: 200

Installation

pip install fastapi-gcp-tasks

Key features

  • Strongly typed tasks.
    • Fail at invocation site to make it easier to develop and debug.
    • Breaking schema changes between versions will fail at task runner with Pydantic.
  • Familiar and simple public API
    • .delay method that takes same arguments as the task.
    • .scheduler method to create recurring job.
    • Async variants (AsyncDelayedRouteBuilder / AsyncScheduledRouteBuilder) so await .delay() never blocks the event loop.
  • Tasks are regular FastAPI endpoints on plain old HTTP.
    • Depends just works!
    • All middlewares, telemetry, auth, debugging etc solutions for FastAPI work as is.
    • Host task runners independent of GCP. If CloudTasks can reach the URL, it can invoke the task.
  • Save money.
  • Autoscale.
    • With a FaaS setup, your task workers can autoscale based on load.
    • Most FaaS services have free tiers making it much cheaper than running a celery worker.

How it works

Delayed job

from fastapi_gcp_tasks import DelayedRouteBuilder

delayed_router = APIRouter(route_class=DelayedRouteBuilder(...))


class Recipe(BaseModel):
  ingredients: List[str]


@delayed_router.post("/{restaurant}/make_dinner")
async def make_dinner(restaurant: str, recipe: Recipe):


# Do a ton of work here.


app.include_router(delayed_router)

Now we can trigger the task with

make_dinner.delay(restaurant="Taj", recipe=Recipe(ingredients=["Pav","Bhaji"]))

If we want to trigger the task 30 minutes later

make_dinner.options(countdown=1800).delay(...)

Scheduled Task

from fastapi_gcp_tasks import ScheduledRouteBuilder

scheduled_router = APIRouter(route_class=ScheduledRouteBuilder(...))


class Recipe(BaseModel):
  ingredients: List[str]


@scheduled_router.post("/home_cook")
async def home_cook(recipe: Recipe):


# Make my own food

app.include_router(scheduled_router)

# If you want to make your own breakfast every morning at 7AM IST.
home_cook.scheduler(name="test-home-cook-at-7AM-IST", schedule="0 7 * * *", time_zone="Asia/Kolkata").schedule(
  recipe=Recipe(ingredients=["Milk", "Cereal"]))

Async usage

DelayedRouteBuilder's .delay() makes a blocking gRPC call. Called from an async endpoint, it stalls the event loop until Cloud Tasks responds. AsyncDelayedRouteBuilder uses the native CloudTasksAsyncClient instead, so triggering a task is a proper coroutine:

from fastapi_gcp_tasks import AsyncDelayedRouteBuilder

async_delayed_router = APIRouter(route_class=AsyncDelayedRouteBuilder(...))


@async_delayed_router.post("/{restaurant}/make_dinner")
async def make_dinner(restaurant: str, recipe: Recipe):
    ...


app.include_router(async_delayed_router)

# In an async context (endpoint, lifespan, etc):
await make_dinner.delay(restaurant="Taj", recipe=Recipe(ingredients=["Pav", "Bhaji"]))
await make_dinner.options(countdown=1800).delay(...)

Similarly, AsyncScheduledRouteBuilder provides awaitable .schedule() and .delete() — useful when creating Cloud Scheduler jobs dynamically from request handlers. Since it can't run at module import time like the sync version, await it from a lifespan (or a handler):

from contextlib import asynccontextmanager

from fastapi_gcp_tasks import AsyncScheduledRouteBuilder

async_scheduled_router = APIRouter(route_class=AsyncScheduledRouteBuilder(...))


@async_scheduled_router.post("/home_cook")
async def home_cook(recipe: Recipe):
    ...


@asynccontextmanager
async def lifespan(app: FastAPI):
    await home_cook.scheduler(name="home-cook-7AM-IST", schedule="0 7 * * *", time_zone="Asia/Kolkata").schedule(
        recipe=Recipe(ingredients=["Milk", "Cereal"])
    )
    yield

Things to know about the async builders:

  • The client is created lazily. grpc.aio clients bind to the event loop that is running when they are constructed, so the builder resolves its client on the first awaited call, inside your app's loop. client accepts a client instance, a zero-argument factory returning one, or None (default credentials). If your client needs custom construction — like the local emulator — pass a factory: client=lambda: async_emulator_client().

  • The queue is not auto-created by default. Unlike DelayedRouteBuilder, auto_create_queue defaults to False so no unexpected RPC runs inside a request handler. Either ensure the queue from your lifespan with the ensure_queue_async util (recommended), or opt in with auto_create_queue=True to ensure it lazily on the first .delay():

    from contextlib import asynccontextmanager
    
    from fastapi_gcp_tasks.utils import ensure_queue_async
    
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        await ensure_queue_async(client=my_async_client, path=MY_QUEUE_PATH)
        yield
    
  • Hooks are unchanged. The same (synchronous) pre_create_hooks work with both builders — they are pure in-memory mutations of the request proto and run inline on the event loop, so they must not block.

Concept

Cloud Tasks allows us to schedule a HTTP request in the future.

FastAPI makes us define complete schema and params for an HTTP endpoint.

Cloud Scheduler allows us to schedule recurring HTTP requests in the future.

FastAPI Cloud Tasks works by putting the three together:

  • GCP's Cloud Tasks + FastAPI = Partial replacement for celery's async delayed tasks.
  • GCP's Cloud Scheduler + FastAPI = Replacement for celery beat.
  • FastAPI Cloud Tasks + Cloud Run = Autoscaled delayed tasks.

Running

Local

Pre-requisites:

  • pip install fastapi-gcp-tasks
  • Install cloud-tasks-emulator
    • Alternatively install ngrok and forward the server's port

Start running the emulator in a terminal

cloud-tasks-emulator

Start running the task runner on port 8000 so that it is accessible from cloud tasks.

uvicorn examples.simple.main:app --reload --port 8000

In another terminal, trigger the task with curl

curl http://localhost:8000/trigger

Check the logs on the server, you should see

WARNING:  Hello task ran with payload: Triggered task

Important bits of code:

# complete file: examples/simple/main.py

# For local, we connect to the emulator client
client = None
if IS_LOCAL:
 client = emulator_client()

# Construct our DelayedRoute class with all relevant settings
# This can be done once across the entire project
DelayedRoute = DelayedRouteBuilder(
    client=client,
    base_url="http://localhost:8000"
    queue_path=queue_path(
        project="gcp-project-id",
        location="us-central1",
        queue="test-queue",
    ),
)

# Override the route_class so that we can add .delay method to the endpoints and know their complete URL
delayed_router = APIRouter(route_class=DelayedRoute, prefix="/delayed")

class Payload(BaseModel):
    message: str

@delayed_router.post("/hello")
async def hello(p: Payload = Payload(message="Default")):
    logger.warning(f"Hello task ran with payload: {p.message}")


# Define our app and add trigger to it.
app = FastAPI()

@app.get("/trigger")
async def trigger():
    # Trigger the task
    hello.delay(p=Payload(message="Triggered task"))
    return {"message": "Hello task triggered"}

app.include_router(delayed_router)

Note: You can read complete working source code of the above example in examples/simple/main.py

In the real world you'd have a separate process for task runner and actual task.

Deployed environment / Cloud Run

Running on Cloud Run with authentication needs us to supply an OIDC token. To do that we can use a hook.

Pre-requisites:

  • Create a task queue. Copy the project id, location and queue name.
  • Deploy the worker as a service on Cloud Run and copy it's URL.
  • Create a service account in cloud IAM and add Cloud Run Invoker role to it.
# URL of the Cloud Run service
base_url = "https://hello-randomchars-el.a.run.app"

DelayedRoute = DelayedRouteBuilder(
    base_url=base_url,
    # Task queue, same as above.
    queue_path=queue_path(...),
    pre_create_hook=oidc_task_hook(
        token=tasks_v2.OidcToken(
            # Service account that you created
            service_account_email="fastapi-gcp-tasks@gcp-project-id.iam.gserviceaccount.com",
            audience=base_url,
        ),
    ),
)

Check the fleshed out example at examples/full/tasks.py

If you're not running on CloudRun and want to an OAuth Token instead, you can use the oauth_task_hook instead.

Check fastapi_cloud_tasks/hooks.py to get the hang od hooks and how you can use them.

Configuration

DelayedRouteBuilder

Usage:

DelayedRoute = DelayedRouteBuilder(...)
delayed_router = APIRouter(route_class=DelayedRoute)

@delayed_router.get("/simple_task")
def simple_task():
    return {}
  • base_url - The URL of your worker FastAPI service.

  • queue_path - Full path of the Cloud Tasks queue. (Hint: use the util function queue_path)

  • task_create_timeout - How long should we wait before giving up on creating cloud task.

  • pre_create_hook - If you need to edit the CreateTaskRequest before sending it to Cloud Tasks (eg: Auth for Cloud Run), you can do that with this hook. See hooks section below for more.

  • client - If you need to override the Cloud Tasks client, pass the client here. (eg: changing credentials, transport etc)

Task level default options

Usage:

@delayed_router.get("/simple_task")
@task_default_options(...)
def simple_task():
    return {}

All options from above can be passed as kwargs to the decorator.

Additional options:

  • countdown - Seconds in the future to schedule the task.
  • task_id - named task id for deduplication. (One task id will only be queued once.)

Example:

# Trigger after 5 minutes
@delayed_router.get("/simple_task")
@task_default_options(countdown=300)
def simple_task():
    return {}

Delayer Options

Usage:

simple_task.options(...).delay()

All options from above can be overwritten per call (including DelayedRouteBuilder options like base_url) with kwargs to the options function before calling delay.

Example:

# Trigger after 2 minutes
simple_task.options(countdown=120).delay()

AsyncDelayedRouteBuilder

Same options as DelayedRouteBuilder, with two differences:

  • client - A CloudTasksAsyncClient, a zero-argument factory returning one, or None. Resolved lazily on the first awaited .delay() because grpc.aio clients bind to the running event loop.
  • auto_create_queue - Defaults to False (the sync builder defaults to True). When True, the queue is ensured lazily on the first .delay(). Prefer calling ensure_queue_async from your lifespan instead.

ScheduledRouteBuilder

Usage:

ScheduledRoute = ScheduledRouteBuilder(...)
scheduled_router = APIRouter(route_class=ScheduledRoute)

@scheduled_router.get("/simple_scheduled_task")
def simple_scheduled_task():
    return {}


simple_scheduled_task.scheduler(name="simple_scheduled_task", schedule="* * * * *").schedule()

AsyncScheduledRouteBuilder

Same options as ScheduledRouteBuilder, except client accepts a CloudSchedulerAsyncClient, a zero-argument factory returning one, or None (resolved lazily, as above). .schedule() and .delete() are coroutines — await them from a lifespan or a request handler.

Hooks

We might need to override things in the task being sent to Cloud Tasks. The pre_create_hook allows us to do that.

Some hooks are included in the library.

  • oidc_delayed_hook / oidc_scheduled_hook - Used to pass OIDC token (for Cloud Run etc).
  • deadline_delayed_hook / deadline_scheduled_hook - Used to change the timeout for the worker of a task. (PS: this deadline is decided by the sender to the queue and not the worker)
  • chained_hook - If you need to chain multiple hooks together, you can do that with chained_hook(hook1, hook2)

Helper dependencies

max_retries

@delayed_router.post("/fail_twice", dependencies=[Depends(max_retries(2))])
async def fail_twice():
    raise Exception("nooo")

CloudTasksHeaders

@delayed_router.get("/my_task")
async def my_task(ct_headers: CloudTasksHeaders = Depends()):
    print(ct_headers.queue_name)

Check the file fastapi_cloud_tasks/dependencies.py for details.

Development

Prerequisites

  • uv
  • Docker (for the Cloud Tasks emulator)

Running tests

docker compose up -d       # start emulator
make test                  # run tests
docker compose down        # stop emulator

Linting & formatting

make lint                  # check
make format                # auto-fix

Contributing

  • Run make lint and make format before raising a PR.
  • Add examples and/or tests for new features.
  • If the change is massive, open an issue to discuss it before writing code.

License

This project is licensed under the terms of the MIT license. This project was forked from fastapi-gcp-tasks under the MIT license. All changes made to the original project are also licensed under the MIT license.

Disclaimer

This project is neither affiliated with, nor sponsored by Google.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_gcp_tasks-0.2.0.tar.gz (70.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_gcp_tasks-0.2.0-py3-none-any.whl (25.7 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_gcp_tasks-0.2.0.tar.gz.

File metadata

  • Download URL: fastapi_gcp_tasks-0.2.0.tar.gz
  • Upload date:
  • Size: 70.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_gcp_tasks-0.2.0.tar.gz
Algorithm Hash digest
SHA256 d1af8b6c000887379e4319f88862d41335ff9b4e5d86f78e04589e7e7f4bb409
MD5 4a2bd8269d2fb7d84ee4ebeb67046f93
BLAKE2b-256 c4f409c6945bbacf9c1a297a9a2211e4374b9a83f31fdbc6d4b8833578572c31

See more details on using hashes here.

File details

Details for the file fastapi_gcp_tasks-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: fastapi_gcp_tasks-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 25.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_gcp_tasks-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 86a6ac22752606e610b9c4a41317a92ff912a405e845e1880a31fd27d3079ef2
MD5 1b0257bdd7c2f248263efd70dd389e16
BLAKE2b-256 e5f5f368ca8ece155862b4351af88374505983bf1ecd48328b7b5272160f95f7

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page