├── requirements.txt ├── local-requirements.txt ├── examples ├── full │ ├── serializer.py │ ├── settings.py │ ├── main.py │ └── tasks.py └── simple │ └── main.py ├── fastapi_cloud_tasks ├── decorators.py ├── __init__.py ├── exception.py ├── utils.py ├── dependencies.py ├── scheduled_route.py ├── delayed_route.py ├── delayer.py ├── hooks.py ├── requester.py └── scheduler.py ├── .flake8 ├── .isort.cfg ├── .pre-commit-config.yaml ├── setup.py ├── .github └── workflows │ └── pypi-publish.yaml ├── LICENSE ├── .gitignore └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | google-cloud-tasks==2.7.0 2 | google-cloud-scheduler==2.5.0 3 | fastapi==0.100.0 -------------------------------------------------------------------------------- /local-requirements.txt: -------------------------------------------------------------------------------- 1 | -r ./requirements.txt 2 | black==21.12b0 3 | flake8==4.0.1 4 | uvicorn==0.15.0 5 | isort==5.10.1 6 | pre-commit==2.16.0 7 | -------------------------------------------------------------------------------- /examples/full/serializer.py: -------------------------------------------------------------------------------- 1 | # Third Party Imports 2 | from pydantic.v1 import BaseModel 3 | 4 | 5 | class Payload(BaseModel): 6 | message: str 7 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/decorators.py: -------------------------------------------------------------------------------- 1 | def task_default_options(**kwargs): 2 | def wrapper(fn): 3 | fn._delayOptions = kwargs 4 | return fn 5 | 6 | return wrapper 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E128, E203, E225, E266, E231, E501, E712, W503, C901, F403, F401, F841 3 | max-line-length = 119 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # Imports from this repository 2 | from fastapi_cloud_tasks.delayed_route import DelayedRouteBuilder 3 | from fastapi_cloud_tasks.scheduled_route import ScheduledRouteBuilder 4 | 5 | __all__ = ["DelayedRouteBuilder", "ScheduledRouteBuilder"] 6 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=119 3 | force_single_line=True 4 | import_heading_stdlib=Standard Library Imports 5 | import_heading_thirdparty=Third Party Imports 6 | import_heading_firstparty=Imports from this repository 7 | import_heading_localfolder=Imports from this module 8 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/exception.py: -------------------------------------------------------------------------------- 1 | # Third Party Imports 2 | from pydantic.v1.errors import MissingError 3 | from pydantic.v1.errors import PydanticValueError 4 | 5 | 6 | class MissingParamError(MissingError): 7 | msg_template = "field required: {param}" 8 | 9 | 10 | class WrongTypeError(PydanticValueError): 11 | msg_template = "Expected {field} to be of type {type}" 12 | 13 | 14 | class BadMethodException(Exception): 15 | pass 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/timothycrosley/isort 3 | rev: "5.10.1" 4 | hooks: 5 | - id: isort 6 | args: [--force-single-line-imports] 7 | - repo: https://github.com/psf/black 8 | rev: 22.3.0 9 | hooks: 10 | - id: black 11 | - repo: https://gitlab.com/pycqa/flake8 12 | rev: 4.0.1 13 | hooks: 14 | - id: flake8 15 | additional_dependencies: [flake8-print==4.0.0] 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Third Party Imports 2 | from setuptools import setup 3 | 4 | with open("version.txt") as f: 5 | version = f.read().strip() 6 | 7 | with open("README.md", encoding="utf-8") as f: 8 | long_description = f.read().strip() 9 | 10 | 11 | setup( 12 | name="fastapi-cloud-tasks", 13 | version=version, 14 | description="Trigger delayed Cloud Tasks from FastAPI", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | licesnse="MIT", 18 | packages=["fastapi_cloud_tasks"], 19 | install_requires=["google-cloud-tasks", "google-cloud-scheduler", "fastapi"], 20 | test_requires=[], 21 | zip_safe=False, 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 29 | run: | 30 | echo ${{github.event.release.tag_name}} > version.txt 31 | python setup.py sdist bdist_wheel 32 | twine upload dist/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Adori Labs, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/utils.py: -------------------------------------------------------------------------------- 1 | # Third Party Imports 2 | import grpc 3 | from google.api_core.exceptions import AlreadyExists 4 | from google.cloud import scheduler_v1 5 | from google.cloud import tasks_v2 6 | from google.cloud.tasks_v2.services.cloud_tasks import transports 7 | 8 | 9 | def location_path(*, project: str, location: str, **ignored): 10 | return scheduler_v1.CloudSchedulerClient.common_location_path( 11 | project=project, location=location 12 | ) 13 | 14 | 15 | def queue_path(*, project: str, location: str, queue: str): 16 | return tasks_v2.CloudTasksClient.queue_path( 17 | project=project, location=location, queue=queue 18 | ) 19 | 20 | 21 | def ensure_queue( 22 | *, 23 | client: tasks_v2.CloudTasksClient, 24 | path: str, 25 | **kwargs, 26 | ): 27 | # We extract information from the queue path to make the public api simpler 28 | parsed_queue_path = client.parse_queue_path(path=path) 29 | create_req = tasks_v2.CreateQueueRequest( 30 | parent=location_path(**parsed_queue_path), 31 | queue=tasks_v2.Queue(name=path, **kwargs), 32 | ) 33 | try: 34 | client.create_queue(request=create_req) 35 | except AlreadyExists: 36 | pass 37 | 38 | 39 | def emulator_client(*, host="localhost:8123"): 40 | channel = grpc.insecure_channel(host) 41 | transport = transports.CloudTasksGrpcTransport(channel=channel) 42 | return tasks_v2.CloudTasksClient(transport=transport) 43 | -------------------------------------------------------------------------------- /examples/simple/main.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | import logging 3 | import os 4 | 5 | # Third Party Imports 6 | from fastapi import FastAPI 7 | from fastapi.routing import APIRouter 8 | from pydantic.v1 import BaseModel 9 | 10 | # Imports from this repository 11 | from fastapi_cloud_tasks import DelayedRouteBuilder 12 | from fastapi_cloud_tasks.utils import emulator_client 13 | from fastapi_cloud_tasks.utils import queue_path 14 | 15 | # set env var IS_LOCAL=false for your deployment environment 16 | IS_LOCAL = os.getenv("IS_LOCAL", "true").lower() == "true" 17 | 18 | client = None 19 | if IS_LOCAL: 20 | client = emulator_client() 21 | 22 | 23 | DelayedRoute = DelayedRouteBuilder( 24 | client=client, 25 | # Base URL where the task server will get hosted 26 | base_url=os.getenv("TASK_LISTENER_BASE_URL", default="http://localhost:8000"), 27 | # Full queue path to which we'll send tasks. 28 | # Edit values below to match your project 29 | queue_path=queue_path( 30 | project=os.getenv("TASK_PROJECT_ID", default="gcp-project-id"), 31 | location=os.getenv("TASK_LOCATION", default="asia-south1"), 32 | queue=os.getenv("TASK_QUEUE", default="test-queue"), 33 | ), 34 | ) 35 | 36 | delayed_router = APIRouter(route_class=DelayedRoute, prefix="/delayed") 37 | 38 | logger = logging.getLogger("uvicorn") 39 | 40 | 41 | class Payload(BaseModel): 42 | message: str 43 | 44 | 45 | @delayed_router.post("/hello") 46 | async def hello( 47 | p: Payload = Payload(message="Default"), 48 | ): 49 | logger.warning(f"Hello task ran with payload: {p.message}") 50 | 51 | 52 | app = FastAPI() 53 | 54 | 55 | @app.get("/trigger") 56 | async def trigger(): 57 | hello.delay(p=Payload(message="Triggered task")) 58 | return {"message": "Basic hello task triggered"} 59 | 60 | 61 | app.include_router(delayed_router) 62 | -------------------------------------------------------------------------------- /examples/full/settings.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | import os 3 | 4 | # Third Party Imports 5 | from google.cloud import scheduler_v1 6 | from google.cloud import tasks_v2 7 | 8 | # Imports from this repository 9 | from fastapi_cloud_tasks.utils import location_path 10 | from fastapi_cloud_tasks.utils import queue_path 11 | 12 | # set env var IS_LOCAL=false for your deployment environment 13 | IS_LOCAL = os.getenv("IS_LOCAL", "true").lower() == "true" 14 | 15 | # The suffix _fastapi_cloud_tasks is a trick for running both main and task server in the same process for local 16 | # In a deployed environment, you'd most likely want them to be separate 17 | # Check main.py for how this is used. 18 | TASK_LISTENER_BASE_URL = os.getenv( 19 | "TASK_LISTENER_BASE_URL", default="http://localhost:8000/_fastapi_cloud_tasks" 20 | ) 21 | TASK_PROJECT_ID = os.getenv("TASK_PROJECT_ID", default="sample-project") 22 | TASK_LOCATION = os.getenv("TASK_LOCATION", default="asia-south1") 23 | SCHEDULED_LOCATION = os.getenv("SCHEDULED_LOCATION", default="us-central1") 24 | TASK_QUEUE = os.getenv("TASK_QUEUE", default="test-queue") 25 | 26 | CLOUD_TASKS_EMULATOR_URL = os.getenv("CLOUD_TASKS_EMULATOR_URL", "localhost:8123") 27 | 28 | TASK_SERVICE_ACCOUNT = os.getenv( 29 | "TASK_SERVICE_ACCOUNT", 30 | default=f"fastapi-cloud-tasks@{TASK_PROJECT_ID}.iam.gserviceaccount.com", 31 | ) 32 | 33 | TASK_QUEUE_PATH = queue_path( 34 | project=TASK_PROJECT_ID, 35 | location=TASK_LOCATION, 36 | queue=TASK_QUEUE, 37 | ) 38 | 39 | SCHEDULED_LOCATION_PATH = location_path( 40 | project=TASK_PROJECT_ID, 41 | location=SCHEDULED_LOCATION, 42 | ) 43 | 44 | TASK_OIDC_TOKEN = tasks_v2.OidcToken( 45 | service_account_email=TASK_SERVICE_ACCOUNT, audience=TASK_LISTENER_BASE_URL 46 | ) 47 | SCHEDULED_OIDC_TOKEN = scheduler_v1.OidcToken( 48 | service_account_email=TASK_SERVICE_ACCOUNT, audience=TASK_LISTENER_BASE_URL 49 | ) 50 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/dependencies.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | import typing 3 | from datetime import datetime 4 | 5 | # Third Party Imports 6 | from fastapi import Depends 7 | from fastapi import Header 8 | from fastapi import HTTPException 9 | 10 | 11 | def max_retries(count: int = 20): 12 | """ 13 | Raises a http exception (with status 200) after max retries are exhausted 14 | """ 15 | 16 | def retries_dep(meta: CloudTasksHeaders = Depends()) -> bool: 17 | # count starts from 0 so equality check is required 18 | if meta.retry_count >= count: 19 | raise HTTPException(status_code=200, detail="Max retries exhausted") 20 | 21 | return retries_dep 22 | 23 | 24 | class CloudTasksHeaders: 25 | """ 26 | Extracts known headers sent by Cloud Tasks 27 | 28 | Full list: https://cloud.google.com/tasks/docs/creating-http-target-tasks#handler 29 | """ 30 | 31 | def __init__( 32 | self, 33 | x_cloudtasks_taskretrycount: typing.Optional[int] = Header(0), 34 | x_cloudtasks_taskexecutioncount: typing.Optional[int] = Header(0), 35 | x_cloudtasks_queuename: typing.Optional[str] = Header(""), 36 | x_cloudtasks_taskname: typing.Optional[str] = Header(""), 37 | x_cloudtasks_tasketa: typing.Optional[float] = Header(0), 38 | x_cloudtasks_taskpreviousresponse: typing.Optional[int] = Header(0), 39 | x_cloudtasks_taskretryreason: typing.Optional[str] = Header(""), 40 | ) -> None: 41 | self.retry_count = x_cloudtasks_taskretrycount 42 | self.execution_count = x_cloudtasks_taskexecutioncount 43 | self.queue_name = x_cloudtasks_queuename 44 | self.task_name = x_cloudtasks_taskname 45 | self.eta = datetime.fromtimestamp(x_cloudtasks_tasketa) 46 | self.previous_response = x_cloudtasks_taskpreviousresponse 47 | self.retry_reason = x_cloudtasks_taskretryreason 48 | -------------------------------------------------------------------------------- /examples/full/main.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | from uuid import uuid4 3 | 4 | # Third Party Imports 5 | from fastapi import FastAPI 6 | from fastapi import Response 7 | from fastapi import status 8 | from google.api_core.exceptions import AlreadyExists 9 | 10 | # Imports from this repository 11 | from examples.full.serializer import Payload 12 | from examples.full.settings import IS_LOCAL 13 | from examples.full.tasks import fail_twice 14 | from examples.full.tasks import hello 15 | 16 | app = FastAPI() 17 | 18 | task_id = str(uuid4()) 19 | 20 | 21 | @app.get("/basic") 22 | async def basic(): 23 | hello.delay(p=Payload(message="Basic task")) 24 | return {"message": "Basic hello task scheduled"} 25 | 26 | 27 | @app.get("/with_countdown") 28 | async def with_countdown(): 29 | hello.options(countdown=5).delay(p=Payload(message="Countdown task")) 30 | return {"message": "Countdown hello task scheduled"} 31 | 32 | 33 | @app.get("/deduped") 34 | async def deduped(response: Response): 35 | # Note: this does not work with cloud-tasks-emulator. 36 | try: 37 | hello.options(task_id=task_id).delay(p=Payload(message="Deduped task")) 38 | return {"message": "Deduped hello task scheduled"} 39 | except AlreadyExists as e: 40 | response.status_code = status.HTTP_409_CONFLICT 41 | return {"error": "Could not schedule task.", "reason": str(e)} 42 | 43 | 44 | @app.get("/fail") 45 | async def fail(): 46 | fail_twice.delay() 47 | return { 48 | "message": "The triggered task will fail twice and then be marked done automatically" 49 | } 50 | 51 | 52 | # We can use a trick on local to get all tasks on the same process as the main server. 53 | # In a deployed environment, we'd really want to run 2 separate processes 54 | if IS_LOCAL: 55 | # Imports from this repository 56 | from examples.full.tasks import app as task_app 57 | 58 | app.mount("/_fastapi_cloud_tasks", task_app) 59 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/scheduled_route.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | from typing import Callable 3 | 4 | # Third Party Imports 5 | from fastapi.routing import APIRoute 6 | from google.cloud import scheduler_v1 7 | 8 | # Imports from this repository 9 | from fastapi_cloud_tasks.hooks import ScheduledHook 10 | from fastapi_cloud_tasks.hooks import noop_hook 11 | from fastapi_cloud_tasks.scheduler import Scheduler 12 | 13 | 14 | def ScheduledRouteBuilder( 15 | *, 16 | base_url: str, 17 | location_path: str, 18 | job_create_timeout: float = 10.0, 19 | pre_create_hook: ScheduledHook = None, 20 | client=None, 21 | ): 22 | """ 23 | Returns a Mixin that should be used to override route_class. 24 | 25 | It adds a .scheduler method to the original endpoint. 26 | 27 | Example: 28 | ``` 29 | scheduled_router = APIRouter(route_class=ScheduledRouteBuilder(...), prefix="/scheduled") 30 | 31 | @scheduled_router.get("/simple_scheduled_task") 32 | def simple_scheduled_task(): 33 | # Do work here 34 | 35 | simple_scheduled_task.scheduler(name="simple_scheduled_task", schedule="* * * * *").schedule() 36 | 37 | app.include_router(scheduled_router) 38 | ``` 39 | """ 40 | if client is None: 41 | client = scheduler_v1.CloudSchedulerClient() 42 | 43 | if pre_create_hook is None: 44 | pre_create_hook = noop_hook 45 | 46 | class ScheduledRouteMixin(APIRoute): 47 | def get_route_handler(self) -> Callable: 48 | original_route_handler = super().get_route_handler() 49 | self.endpoint.scheduler = self.schedulerOptions 50 | return original_route_handler 51 | 52 | def schedulerOptions(self, *, name, schedule, **options) -> Scheduler: 53 | schedulerOpts = dict( 54 | base_url=base_url, 55 | location_path=location_path, 56 | client=client, 57 | pre_create_hook=pre_create_hook, 58 | job_create_timeout=job_create_timeout, 59 | name=name, 60 | schedule=schedule, 61 | ) 62 | 63 | schedulerOpts.update(options) 64 | 65 | return Scheduler(route=self, **schedulerOpts) 66 | 67 | return ScheduledRouteMixin 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # pyvenv 132 | pyvenv.cfg 133 | bin/ 134 | 135 | version.txt -------------------------------------------------------------------------------- /fastapi_cloud_tasks/delayed_route.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | import queue 3 | from typing import Callable 4 | 5 | # Third Party Imports 6 | from fastapi.routing import APIRoute 7 | from google.cloud import tasks_v2 8 | 9 | # Imports from this repository 10 | from fastapi_cloud_tasks.delayer import Delayer 11 | from fastapi_cloud_tasks.hooks import DelayedTaskHook 12 | from fastapi_cloud_tasks.hooks import noop_hook 13 | from fastapi_cloud_tasks.utils import ensure_queue 14 | 15 | 16 | def DelayedRouteBuilder( 17 | *, 18 | base_url: str, 19 | queue_path: str, 20 | task_create_timeout: float = 10.0, 21 | pre_create_hook: DelayedTaskHook = None, 22 | client=None, 23 | auto_create_queue=True, 24 | ): 25 | """ 26 | Returns a Mixin that should be used to override route_class. 27 | 28 | It adds a .delay and .options methods to the original endpoint. 29 | 30 | Example: 31 | ``` 32 | delayed_router = APIRouter(route_class=DelayedRouteBuilder(...), prefix="/delayed") 33 | 34 | class UserData(BaseModel): 35 | name: str 36 | 37 | @delayed_router.post("/on_user_create/{user_id}") 38 | def on_user_create(user_id: str, data: UserData): 39 | # do work here 40 | # Return values are meaningless 41 | 42 | # Call .delay to trigger 43 | on_user_create.delay(user_id="007", data=UserData(name="Piyush")) 44 | 45 | app.include_router(delayed_router) 46 | ``` 47 | """ 48 | if client is None: 49 | client = tasks_v2.CloudTasksClient() 50 | 51 | if pre_create_hook is None: 52 | pre_create_hook = noop_hook 53 | 54 | if auto_create_queue: 55 | ensure_queue(client=client, path=queue_path) 56 | 57 | class TaskRouteMixin(APIRoute): 58 | def get_route_handler(self) -> Callable: 59 | original_route_handler = super().get_route_handler() 60 | self.endpoint.options = self.delayOptions 61 | self.endpoint.delay = self.delay 62 | return original_route_handler 63 | 64 | def delayOptions(self, **options) -> Delayer: 65 | delayOpts = dict( 66 | base_url=base_url, 67 | queue_path=queue_path, 68 | task_create_timeout=task_create_timeout, 69 | client=client, 70 | pre_create_hook=pre_create_hook, 71 | ) 72 | if hasattr(self.endpoint, "_delayOptions"): 73 | delayOpts.update(self.endpoint._delayOptions) 74 | delayOpts.update(options) 75 | 76 | return Delayer( 77 | route=self, 78 | **delayOpts, 79 | ) 80 | 81 | def delay(self, **kwargs): 82 | return self.delayOptions().delay(**kwargs) 83 | 84 | return TaskRouteMixin 85 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/delayer.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | import datetime 3 | 4 | # Third Party Imports 5 | from fastapi.routing import APIRoute 6 | from google.cloud import tasks_v2 7 | from google.protobuf import timestamp_pb2 8 | 9 | # Imports from this repository 10 | from fastapi_cloud_tasks.exception import BadMethodException 11 | from fastapi_cloud_tasks.hooks import DelayedTaskHook 12 | from fastapi_cloud_tasks.requester import Requester 13 | 14 | 15 | class Delayer(Requester): 16 | def __init__( 17 | self, 18 | *, 19 | route: APIRoute, 20 | base_url: str, 21 | queue_path: str, 22 | client: tasks_v2.CloudTasksClient, 23 | pre_create_hook: DelayedTaskHook, 24 | task_create_timeout: float = 10.0, 25 | countdown: int = 0, 26 | task_id: str = None, 27 | ) -> None: 28 | super().__init__(route=route, base_url=base_url) 29 | self.queue_path = queue_path 30 | self.countdown = countdown 31 | self.task_create_timeout = task_create_timeout 32 | 33 | self.task_id = task_id 34 | self.method = _task_method(route.methods) 35 | self.client = client 36 | self.pre_create_hook = pre_create_hook 37 | 38 | def delay(self, **kwargs): 39 | # Create http request 40 | request = tasks_v2.HttpRequest() 41 | request.http_method = self.method 42 | request.url = self._url(values=kwargs) 43 | request.headers = self._headers(values=kwargs) 44 | 45 | body = self._body(values=kwargs) 46 | if body: 47 | request.body = body 48 | 49 | # Scheduled the task 50 | task = tasks_v2.Task(http_request=request) 51 | schedule_time = self._schedule() 52 | if schedule_time: 53 | task.schedule_time = schedule_time 54 | 55 | # Make task name for deduplication 56 | if self.task_id: 57 | task.name = f"{self.queue_path}/tasks/{self.task_id}" 58 | 59 | request = tasks_v2.CreateTaskRequest(parent=self.queue_path, task=task) 60 | 61 | request = self.pre_create_hook(request) 62 | 63 | return self.client.create_task( 64 | request=request, timeout=self.task_create_timeout 65 | ) 66 | 67 | def _schedule(self): 68 | if self.countdown is None or self.countdown <= 0: 69 | return None 70 | d = datetime.datetime.utcnow() + datetime.timedelta(seconds=self.countdown) 71 | timestamp = timestamp_pb2.Timestamp() 72 | timestamp.FromDatetime(d) 73 | return timestamp 74 | 75 | 76 | def _task_method(methods): 77 | methodMap = { 78 | "POST": tasks_v2.HttpMethod.POST, 79 | "GET": tasks_v2.HttpMethod.GET, 80 | "HEAD": tasks_v2.HttpMethod.HEAD, 81 | "PUT": tasks_v2.HttpMethod.PUT, 82 | "DELETE": tasks_v2.HttpMethod.DELETE, 83 | "PATCH": tasks_v2.HttpMethod.PATCH, 84 | "OPTIONS": tasks_v2.HttpMethod.OPTIONS, 85 | } 86 | methods = list(methods) 87 | # Only crash if we're being bound 88 | if len(methods) > 1: 89 | raise BadMethodException("Can't trigger task with multiple methods") 90 | method = methodMap.get(methods[0], None) 91 | if method is None: 92 | raise BadMethodException(f"Unknown method {methods[0]}") 93 | return method 94 | -------------------------------------------------------------------------------- /examples/full/tasks.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | import logging 3 | 4 | # Third Party Imports 5 | from fastapi import Depends 6 | from fastapi import FastAPI 7 | from fastapi.routing import APIRouter 8 | from google.protobuf import duration_pb2 9 | 10 | # Imports from this repository 11 | from examples.full.serializer import Payload 12 | from examples.full.settings import CLOUD_TASKS_EMULATOR_URL 13 | from examples.full.settings import IS_LOCAL 14 | from examples.full.settings import SCHEDULED_LOCATION_PATH 15 | from examples.full.settings import SCHEDULED_OIDC_TOKEN 16 | from examples.full.settings import TASK_LISTENER_BASE_URL 17 | from examples.full.settings import TASK_OIDC_TOKEN 18 | from examples.full.settings import TASK_QUEUE_PATH 19 | from fastapi_cloud_tasks import DelayedRouteBuilder 20 | from fastapi_cloud_tasks.dependencies import CloudTasksHeaders 21 | from fastapi_cloud_tasks.dependencies import max_retries 22 | from fastapi_cloud_tasks.hooks import chained_hook 23 | from fastapi_cloud_tasks.hooks import deadline_delayed_hook 24 | from fastapi_cloud_tasks.hooks import deadline_scheduled_hook 25 | from fastapi_cloud_tasks.hooks import oidc_delayed_hook 26 | from fastapi_cloud_tasks.hooks import oidc_scheduled_hook 27 | from fastapi_cloud_tasks.scheduled_route import ScheduledRouteBuilder 28 | from fastapi_cloud_tasks.utils import emulator_client 29 | 30 | app = FastAPI() 31 | 32 | 33 | logger = logging.getLogger("uvicorn") 34 | 35 | delayed_client = None 36 | if IS_LOCAL: 37 | delayed_client = emulator_client(host=CLOUD_TASKS_EMULATOR_URL) 38 | 39 | DelayedRoute = DelayedRouteBuilder( 40 | client=delayed_client, 41 | base_url=TASK_LISTENER_BASE_URL, 42 | queue_path=TASK_QUEUE_PATH, 43 | # Chain multiple hooks together 44 | pre_create_hook=chained_hook( 45 | # Add service account for cloud run 46 | oidc_delayed_hook( 47 | token=TASK_OIDC_TOKEN, 48 | ), 49 | # Wait for half an hour 50 | deadline_delayed_hook(duration=duration_pb2.Duration(seconds=1800)), 51 | ), 52 | ) 53 | 54 | ScheduledRoute = ScheduledRouteBuilder( 55 | base_url=TASK_LISTENER_BASE_URL, 56 | location_path=SCHEDULED_LOCATION_PATH, 57 | pre_create_hook=chained_hook( 58 | # Add service account for cloud run 59 | oidc_scheduled_hook( 60 | token=SCHEDULED_OIDC_TOKEN, 61 | ), 62 | # Wait for half an hour 63 | deadline_scheduled_hook(duration=duration_pb2.Duration(seconds=1800)), 64 | ), 65 | ) 66 | 67 | delayed_router = APIRouter(route_class=DelayedRoute, prefix="/delayed") 68 | 69 | 70 | @delayed_router.post("/hello") 71 | async def hello(p: Payload = Payload(message="Default")): 72 | message = f"Hello task ran with payload: {p.message}" 73 | logger.warning(message) 74 | 75 | 76 | @delayed_router.post("/fail_twice", dependencies=[Depends(max_retries(2))]) 77 | async def fail_twice(): 78 | raise Exception("nooo") 79 | 80 | 81 | scheduled_router = APIRouter(route_class=ScheduledRoute, prefix="/scheduled") 82 | 83 | 84 | @scheduled_router.post("/timed_hello") 85 | async def scheduled_hello(p: Payload = Payload(message="Default")): 86 | message = f"Scheduled hello task ran with payload: {p.message}" 87 | logger.warning(message) 88 | return {"message": message} 89 | 90 | 91 | # We want to schedule tasks only in a deployed environment 92 | if not IS_LOCAL: 93 | scheduled_hello.scheduler( 94 | name="testing-examples-scheduled-hello", 95 | schedule="*/5 * * * *", 96 | time_zone="Asia/Kolkata", 97 | ).schedule(p=Payload(message="Scheduled")) 98 | 99 | app.include_router(delayed_router) 100 | app.include_router(scheduled_router) 101 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/hooks.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | from typing import Callable 3 | 4 | # Third Party Imports 5 | from google.cloud import scheduler_v1 6 | from google.cloud import tasks_v2 7 | from google.protobuf import duration_pb2 8 | 9 | DelayedTaskHook = Callable[[tasks_v2.CreateTaskRequest], tasks_v2.CreateTaskRequest] 10 | ScheduledHook = Callable[[scheduler_v1.CreateJobRequest], scheduler_v1.CreateJobRequest] 11 | 12 | 13 | def noop_hook(request): 14 | """ 15 | Inspired by https://github.com/kelseyhightower/nocode 16 | """ 17 | return request 18 | 19 | 20 | def chained_hook(*hooks): 21 | """ 22 | Call all hooks sequentially with the result from the previous hook 23 | """ 24 | 25 | def chain(request): 26 | for hook in hooks: 27 | request = hook(request) 28 | return request 29 | 30 | return chain 31 | 32 | 33 | def oidc_scheduled_hook(token: scheduler_v1.OidcToken) -> ScheduledHook: 34 | """ 35 | Returns a hook for ScheduledRouteBuilder to add OIDC token to all requests 36 | 37 | https://cloud.google.com/scheduler/docs/reference/rpc/google.cloud.scheduler.v1#google.cloud.scheduler.v1.HttpTarget 38 | """ 39 | 40 | def add_token( 41 | request: scheduler_v1.CreateJobRequest, 42 | ) -> scheduler_v1.CreateJobRequest: 43 | request.job.http_target.oidc_token = token 44 | return request 45 | 46 | return add_token 47 | 48 | 49 | def oidc_delayed_hook(token: tasks_v2.OidcToken) -> DelayedTaskHook: 50 | """ 51 | Returns a hook for DelayedRouteBuilder to add OIDC token to all requests 52 | 53 | https://cloud.google.com/tasks/docs/reference/rpc/google.cloud.tasks.v2#google.cloud.tasks.v2.HttpRequest 54 | """ 55 | 56 | def add_token(request: tasks_v2.CreateTaskRequest) -> tasks_v2.CreateTaskRequest: 57 | request.task.http_request.oidc_token = token 58 | return request 59 | 60 | return add_token 61 | 62 | 63 | def oauth_scheduled_hook(token: scheduler_v1.OAuthToken) -> ScheduledHook: 64 | """ 65 | Returns a hook for ScheduledRouteBuilder to add OAuth token to all requests 66 | 67 | https://cloud.google.com/scheduler/docs/reference/rpc/google.cloud.scheduler.v1#google.cloud.scheduler.v1.HttpTarget 68 | """ 69 | 70 | def add_token( 71 | request: scheduler_v1.CreateJobRequest, 72 | ) -> scheduler_v1.CreateJobRequest: 73 | request.job.http_target.oauth_token = token 74 | return request 75 | 76 | return add_token 77 | 78 | 79 | def oauth_delayed_hook(token: tasks_v2.OAuthToken) -> DelayedTaskHook: 80 | """ 81 | Returns a hook for DelayedRouteBuilder to add OAuth token to all requests 82 | 83 | https://cloud.google.com/tasks/docs/reference/rpc/google.cloud.tasks.v2#google.cloud.tasks.v2.HttpRequest 84 | """ 85 | 86 | def add_token(request: tasks_v2.CreateTaskRequest) -> tasks_v2.CreateTaskRequest: 87 | request.task.http_request.oauth_token = token 88 | return request 89 | 90 | return add_token 91 | 92 | 93 | def deadline_scheduled_hook(duration: duration_pb2.Duration) -> ScheduledHook: 94 | """ 95 | Returns a hook for ScheduledRouteBuilder to set Deadline for job execution 96 | 97 | https://cloud.google.com/scheduler/docs/reference/rpc/google.cloud.scheduler.v1#google.cloud.scheduler.v1.Job 98 | """ 99 | 100 | def deadline( 101 | request: scheduler_v1.CreateJobRequest, 102 | ) -> scheduler_v1.CreateJobRequest: 103 | request.job.attempt_deadline = duration 104 | return request 105 | 106 | return deadline 107 | 108 | 109 | def deadline_delayed_hook(duration: duration_pb2.Duration) -> DelayedTaskHook: 110 | """ 111 | Returns a hook for DelayedRouteBuilder to set Deadline for task execution 112 | 113 | https://cloud.google.com/tasks/docs/reference/rpc/google.cloud.tasks.v2#google.cloud.tasks.v2.Task 114 | """ 115 | 116 | def deadline(request: tasks_v2.CreateTaskRequest) -> tasks_v2.CreateTaskRequest: 117 | request.task.dispatch_deadline = duration 118 | return request 119 | 120 | return deadline 121 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/requester.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | from typing import Dict 3 | from typing import List 4 | from typing import Tuple 5 | from urllib.parse import parse_qsl 6 | from urllib.parse import urlencode 7 | from urllib.parse import urlparse 8 | from urllib.parse import urlunparse 9 | 10 | # Third Party Imports 11 | from fastapi.dependencies.utils import request_params_to_args 12 | from fastapi.encoders import jsonable_encoder 13 | from fastapi.routing import APIRoute 14 | from pydantic.v1.error_wrappers import ErrorWrapper 15 | 16 | # Imports from this repository 17 | from fastapi_cloud_tasks.exception import MissingParamError 18 | from fastapi_cloud_tasks.exception import WrongTypeError 19 | 20 | try: 21 | # Third Party Imports 22 | import ujson as json 23 | except Exception: 24 | # Standard Library Imports 25 | import json 26 | 27 | 28 | class Requester: 29 | def __init__( 30 | self, 31 | *, 32 | route: APIRoute, 33 | base_url: str, 34 | ) -> None: 35 | self.route = route 36 | self.base_url = base_url.rstrip("/") 37 | 38 | def _headers(self, *, values): 39 | headers = _err_val( 40 | request_params_to_args(self.route.dependant.header_params, values) 41 | ) 42 | cookies = _err_val( 43 | request_params_to_args(self.route.dependant.cookie_params, values) 44 | ) 45 | if len(cookies) > 0: 46 | headers["Cookies"] = "; ".join([f"{k}={v}" for (k, v) in cookies.items()]) 47 | # We use json only. 48 | headers["Content-Type"] = "application/json" 49 | # Always send string headers and skip all headers which are supposed to be sent by cloudtasks 50 | return { 51 | str(k): str(v) 52 | for (k, v) in headers.items() 53 | if not str(k).startswith("x_cloudtasks_") 54 | } 55 | 56 | def _url(self, *, values): 57 | route = self.route 58 | path_values = _err_val( 59 | request_params_to_args(route.dependant.path_params, values) 60 | ) 61 | for (name, converter) in route.param_convertors.items(): 62 | if name in path_values: 63 | continue 64 | if name not in values: 65 | raise MissingParamError(param=name) 66 | 67 | # TODO: should we catch errors here and raise better errors? 68 | path_values[name] = converter.convert(values[name]) 69 | path = route.path_format.format(**path_values) 70 | params = _err_val(request_params_to_args(route.dependant.query_params, values)) 71 | 72 | # Make final URL 73 | 74 | # Split base url into parts 75 | url_parts = list(urlparse(self.base_url)) 76 | 77 | # Add relative path 78 | # Note: you might think urljoin is a better solution here, it is not. 79 | url_parts[2] = url_parts[2].strip("/") + "/" + path.strip("/") 80 | 81 | # Make query dict and update our with our params 82 | query = dict(parse_qsl(url_parts[4])) 83 | query.update(params) 84 | 85 | # override query params 86 | url_parts[4] = urlencode(query) 87 | return urlunparse(url_parts) 88 | 89 | def _body(self, *, values): 90 | body = None 91 | body_field = self.route.body_field 92 | if body_field and body_field.name: 93 | got_body = values.get(body_field.name, None) 94 | if got_body is None: 95 | if body_field.required: 96 | raise MissingParamError(name=body_field.name) 97 | got_body = body_field.get_default() 98 | if not isinstance(got_body, body_field.type_): 99 | raise WrongTypeError(field=body_field.name, type=body_field.type_) 100 | body = json.dumps(jsonable_encoder(got_body)).encode() 101 | return body 102 | 103 | 104 | def _err_val(resp: Tuple[Dict, List[ErrorWrapper]]): 105 | values, errors = resp 106 | 107 | if len(errors) != 0: 108 | # TODO: Log everything but raise first only 109 | # TODO: find a better way to raise and display these errors 110 | raise errors[0].exc 111 | return values 112 | -------------------------------------------------------------------------------- /fastapi_cloud_tasks/scheduler.py: -------------------------------------------------------------------------------- 1 | # Standard Library Imports 2 | 3 | # Third Party Imports 4 | from fastapi.routing import APIRoute 5 | from google.cloud import scheduler_v1 6 | from google.protobuf import duration_pb2 7 | 8 | # Imports from this repository 9 | from fastapi_cloud_tasks.exception import BadMethodException 10 | from fastapi_cloud_tasks.hooks import ScheduledHook 11 | from fastapi_cloud_tasks.requester import Requester 12 | 13 | 14 | class Scheduler(Requester): 15 | def __init__( 16 | self, 17 | *, 18 | route: APIRoute, 19 | base_url: str, 20 | location_path: str, 21 | schedule: str, 22 | client: scheduler_v1.CloudSchedulerClient, 23 | pre_create_hook: ScheduledHook, 24 | name: str = "", 25 | job_create_timeout: float = 10.0, 26 | retry_config: scheduler_v1.RetryConfig = None, 27 | time_zone: str = "UTC", 28 | force: bool = False, 29 | ) -> None: 30 | super().__init__(route=route, base_url=base_url) 31 | if name == "": 32 | name = route.unique_id 33 | 34 | if retry_config is None: 35 | retry_config = scheduler_v1.RetryConfig( 36 | retry_count=5, 37 | max_retry_duration=duration_pb2.Duration(seconds=0), 38 | min_backoff_duration=duration_pb2.Duration(seconds=5), 39 | max_backoff_duration=duration_pb2.Duration(seconds=120), 40 | max_doublings=5, 41 | ) 42 | 43 | self.retry_config = retry_config 44 | location_parts = client.parse_common_location_path(location_path) 45 | 46 | self.job_id = client.job_path(job=name, **location_parts) 47 | self.time_zone = time_zone 48 | 49 | self.location_path = location_path 50 | self.cron_schedule = schedule 51 | self.job_create_timeout = job_create_timeout 52 | 53 | self.method = _scheduler_method(route.methods) 54 | self.client = client 55 | self.pre_create_hook = pre_create_hook 56 | self.force = force 57 | 58 | def schedule(self, **kwargs): 59 | # Create http request 60 | request = scheduler_v1.HttpTarget() 61 | request.http_method = self.method 62 | request.uri = self._url(values=kwargs) 63 | request.headers = self._headers(values=kwargs) 64 | 65 | body = self._body(values=kwargs) 66 | if body: 67 | request.body = body 68 | 69 | # Scheduled the task 70 | job = scheduler_v1.Job( 71 | name=self.job_id, 72 | http_target=request, 73 | schedule=self.cron_schedule, 74 | retry_config=self.retry_config, 75 | time_zone=self.time_zone, 76 | ) 77 | 78 | request = scheduler_v1.CreateJobRequest(parent=self.location_path, job=job) 79 | 80 | request = self.pre_create_hook(request) 81 | 82 | if self.force or self._has_changed(request=request): 83 | # Delete and create job 84 | self.delete() 85 | self.client.create_job(request=request, timeout=self.job_create_timeout) 86 | 87 | def _has_changed(self, request: scheduler_v1.CreateJobRequest): 88 | try: 89 | job = self.client.get_job(name=request.job.name) 90 | # Remove things that are either output only or GCP adds by default 91 | job.user_update_time = None 92 | job.state = None 93 | job.status = None 94 | job.last_attempt_time = None 95 | job.schedule_time = None 96 | del job.http_target.headers["User-Agent"] 97 | # Proto compare works directly with `__eq__` 98 | return job != request.job 99 | except Exception: 100 | return True 101 | return False 102 | 103 | def delete(self): 104 | # We return true or exception because you could have the delete code on multiple instances 105 | try: 106 | self.client.delete_job(name=self.job_id, timeout=self.job_create_timeout) 107 | return True 108 | except Exception as ex: 109 | return ex 110 | 111 | 112 | def _scheduler_method(methods): 113 | methodMap = { 114 | "POST": scheduler_v1.HttpMethod.POST, 115 | "GET": scheduler_v1.HttpMethod.GET, 116 | "HEAD": scheduler_v1.HttpMethod.HEAD, 117 | "PUT": scheduler_v1.HttpMethod.PUT, 118 | "DELETE": scheduler_v1.HttpMethod.DELETE, 119 | "PATCH": scheduler_v1.HttpMethod.PATCH, 120 | "OPTIONS": scheduler_v1.HttpMethod.OPTIONS, 121 | } 122 | methods = list(methods) 123 | # Only crash if we're being bound 124 | if len(methods) > 1: 125 | raise BadMethodException("Can't schedule task with multiple methods") 126 | method = methodMap.get(methods[0], None) 127 | if method is None: 128 | raise BadMethodException(f"Unknown method {methods[0]}") 129 | return method 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Cloud Tasks 2 | 3 | Strongly typed background tasks with FastAPI and Google CloudTasks. 4 | 5 | ```mermaid 6 | sequenceDiagram 7 | autonumber 8 | actor User 9 | participant Service 10 | participant CloudTasks 11 | participant Worker 12 | 13 | 14 | User ->>+ Service: /trigger 15 | 16 | 17 | rect rgb(100,130,180) 18 | note right of Service: hello.delay() 19 | Service -->>+ CloudTasks: Create task 20 | CloudTasks -->>- Service: Accepted 21 | end 22 | 23 | Service ->>- User: Hello task triggered 24 | note right of CloudTasks: Async 25 | CloudTasks -->>+ Worker: /hello 26 | Worker -->>- CloudTasks: 200 27 | 28 | ``` 29 | 30 | ## Installation 31 | 32 | ``` 33 | pip install fastapi-cloud-tasks 34 | ``` 35 | 36 | ## Key features 37 | 38 | - Strongly typed tasks. 39 | - Fail at invocation site to make it easier to develop and debug. 40 | - Breaking schema changes between versions will fail at task runner with Pydantic. 41 | - Familiar and simple public API 42 | - `.delay` method that takes same arguments as the task. 43 | - `.scheduler` method to create recurring job. 44 | - Tasks are regular FastAPI endpoints on plain old HTTP. 45 | - `Depends` just works! 46 | - All middlewares, telemetry, auth, debugging etc solutions for FastAPI work as is. 47 | - Host task runners independent of GCP. If CloudTasks can reach the URL, it can invoke the task. 48 | - Save money. 49 | - Task invocation with GCP is [free for first million, then costs $0.4/million](https://cloud.google.com/tasks/pricing). 50 | That's almost always cheaper than running a RabbitMQ/Redis/SQL backend for celery. 51 | - Jobs cost [$0.1 per job per month irrespective of invocations. 3 jobs are free.](https://cloud.google.com/scheduler#pricing) 52 | Either free or almost always cheaper than always running beat worker. 53 | - If somehow, this cost ever becomes a concern, the `client` can be overriden to call any gRPC server with a compatible API. 54 | [Here's a trivial emulator implementation that we will use locally](https://github.com/aertje/cloud-tasks-emulator) 55 | - Autoscale. 56 | - With a FaaS setup, your task workers can autoscale based on load. 57 | - Most FaaS services have free tiers making it much cheaper than running a celery worker. 58 | 59 | ## How it works 60 | 61 | ### Delayed job 62 | 63 | ```python 64 | from fastapi_cloud_tasks import DelayedRouteBuilder 65 | 66 | delayed_router = APIRouter(route_class=DelayedRouteBuilder(...)) 67 | 68 | class Recipe(BaseModel): 69 | ingredients: List[str] 70 | 71 | @delayed_router.post("/{restaurant}/make_dinner") 72 | async def make_dinner(restaurant: str, recipe: Recipe): 73 | # Do a ton of work here. 74 | 75 | 76 | app.include_router(delayed_router) 77 | ``` 78 | 79 | Now we can trigger the task with 80 | 81 | ```python 82 | make_dinner.delay(restaurant="Taj", recipe=Recipe(ingredients=["Pav","Bhaji"])) 83 | ``` 84 | 85 | If we want to trigger the task 30 minutes later 86 | 87 | ```python 88 | make_dinner.options(countdown=1800).delay(...) 89 | ``` 90 | 91 | ### Scheduled Task 92 | ```python 93 | from fastapi_cloud_tasks import ScheduledRouteBuilder 94 | 95 | scheduled_router = APIRouter(route_class=ScheduledRouteBuilder(...)) 96 | 97 | class Recipe(BaseModel): 98 | ingredients: List[str] 99 | 100 | @scheduled_router.post("/home_cook") 101 | async def home_cook(recipe: Recipe): 102 | # Make my own food 103 | 104 | app.include_router(scheduled_router) 105 | 106 | # If you want to make your own breakfast every morning at 7AM IST. 107 | home_cook.scheduler(name="test-home-cook-at-7AM-IST", schedule="0 7 * * *", time_zone="Asia/Kolkata").schedule(recipe=Recipe(ingredients=["Milk","Cereal"])) 108 | ``` 109 | 110 | ## Concept 111 | 112 | [`Cloud Tasks`](https://cloud.google.com/tasks) allows us to schedule a HTTP request in the future. 113 | 114 | [FastAPI](https://fastapi.tiangolo.com/tutorial/body/) makes us define complete schema and params for an HTTP endpoint. 115 | 116 | [`Cloud Scheduler`](https://cloud.google.com/scheduler) allows us to schedule recurring HTTP requests in the future. 117 | 118 | FastAPI Cloud Tasks works by putting the three together: 119 | 120 | - GCP's Cloud Tasks + FastAPI = Partial replacement for celery's async delayed tasks. 121 | - GCP's Cloud Scheduler + FastAPI = Replacement for celery beat. 122 | - FastAPI Cloud Tasks + Cloud Run = Autoscaled delayed tasks. 123 | 124 | 125 | 126 | ## Running 127 | 128 | ### Local 129 | 130 | Pre-requisites: 131 | - `pip install fastapi-cloud-tasks` 132 | - Install [cloud-tasks-emulator](https://github.com/aertje/cloud-tasks-emulator) 133 | - Alternatively install ngrok and forward the server's port 134 | 135 | Start running the emulator in a terminal 136 | ```sh 137 | cloud-tasks-emulator 138 | ``` 139 | 140 | Start running the task runner on port 8000 so that it is accessible from cloud tasks. 141 | 142 | ```sh 143 | uvicorn examples.simple.main:app --reload --port 8000 144 | ``` 145 | 146 | In another terminal, trigger the task with curl 147 | 148 | ``` 149 | curl http://localhost:8000/trigger 150 | ``` 151 | 152 | Check the logs on the server, you should see 153 | 154 | ``` 155 | WARNING: Hello task ran with payload: Triggered task 156 | ``` 157 | 158 | Important bits of code: 159 | 160 | ```python 161 | # complete file: examples/simple/main.py 162 | 163 | # For local, we connect to the emulator client 164 | client = None 165 | if IS_LOCAL: 166 | client = emulator_client() 167 | 168 | # Construct our DelayedRoute class with all relevant settings 169 | # This can be done once across the entire project 170 | DelayedRoute = DelayedRouteBuilder( 171 | client=client, 172 | base_url="http://localhost:8000" 173 | queue_path=queue_path( 174 | project="gcp-project-id", 175 | location="asia-south1", 176 | queue="test-queue", 177 | ), 178 | ) 179 | 180 | # Override the route_class so that we can add .delay method to the endpoints and know their complete URL 181 | delayed_router = APIRouter(route_class=DelayedRoute, prefix="/delayed") 182 | 183 | class Payload(BaseModel): 184 | message: str 185 | 186 | @delayed_router.post("/hello") 187 | async def hello(p: Payload = Payload(message="Default")): 188 | logger.warning(f"Hello task ran with payload: {p.message}") 189 | 190 | 191 | # Define our app and add trigger to it. 192 | app = FastAPI() 193 | 194 | @app.get("/trigger") 195 | async def trigger(): 196 | # Trigger the task 197 | hello.delay(p=Payload(message="Triggered task")) 198 | return {"message": "Hello task triggered"} 199 | 200 | app.include_router(delayed_router) 201 | 202 | ``` 203 | 204 | Note: You can read complete working source code of the above example in [`examples/simple/main.py`](examples/simple/main.py) 205 | 206 | In the real world you'd have a separate process for task runner and actual task. 207 | 208 | ### Deployed environment / Cloud Run 209 | 210 | Running on Cloud Run with authentication needs us to supply an OIDC token. To do that we can use a `hook`. 211 | 212 | Pre-requisites: 213 | 214 | - Create a task queue. Copy the project id, location and queue name. 215 | - Deploy the worker as a service on Cloud Run and copy it's URL. 216 | - Create a service account in cloud IAM and add `Cloud Run Invoker` role to it. 217 | 218 | 219 | ```python 220 | # URL of the Cloud Run service 221 | base_url = "https://hello-randomchars-el.a.run.app" 222 | 223 | DelayedRoute = DelayedRouteBuilder( 224 | base_url=base_url, 225 | # Task queue, same as above. 226 | queue_path=queue_path(...), 227 | pre_create_hook=oidc_task_hook( 228 | token=tasks_v2.OidcToken( 229 | # Service account that you created 230 | service_account_email="fastapi-cloud-tasks@gcp-project-id.iam.gserviceaccount.com", 231 | audience=base_url, 232 | ), 233 | ), 234 | ) 235 | ``` 236 | 237 | Check the fleshed out example at [`examples/full/tasks.py`](examples/full/tasks.py) 238 | 239 | If you're not running on CloudRun and want to an OAuth Token instead, you can use the `oauth_task_hook` instead. 240 | 241 | Check [fastapi_cloud_tasks/hooks.py](fastapi_cloud_tasks/hooks.py) to get the hang od hooks and how you can use them. 242 | 243 | ## Configuration 244 | 245 | ### DelayedRouteBuilder 246 | 247 | Usage: 248 | 249 | ```python 250 | DelayedRoute = DelayedRouteBuilder(...) 251 | delayed_router = APIRouter(route_class=DelayedRoute) 252 | 253 | @delayed_router.get("/simple_task") 254 | def simple_task(): 255 | return {} 256 | ``` 257 | 258 | - `base_url` - The URL of your worker FastAPI service. 259 | 260 | - `queue_path` - Full path of the Cloud Tasks queue. (Hint: use the util function `queue_path`) 261 | 262 | - `task_create_timeout` - How long should we wait before giving up on creating cloud task. 263 | 264 | - `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. 265 | 266 | - `client` - If you need to override the Cloud Tasks client, pass the client here. (eg: changing credentials, transport etc) 267 | 268 | #### Task level default options 269 | 270 | Usage: 271 | 272 | ```python 273 | @delayed_router.get("/simple_task") 274 | @task_default_options(...) 275 | def simple_task(): 276 | return {} 277 | ``` 278 | 279 | All options from above can be passed as `kwargs` to the decorator. 280 | 281 | Additional options: 282 | 283 | - `countdown` - Seconds in the future to schedule the task. 284 | - `task_id` - named task id for deduplication. (One task id will only be queued once.) 285 | 286 | Example: 287 | 288 | ```python 289 | # Trigger after 5 minutes 290 | @delayed_router.get("/simple_task") 291 | @task_default_options(countdown=300) 292 | def simple_task(): 293 | return {} 294 | ``` 295 | 296 | #### Delayer Options 297 | 298 | Usage: 299 | 300 | ```python 301 | simple_task.options(...).delay() 302 | ``` 303 | 304 | All options from above can be overriden per call (including DelayedRouteBuilder options like `base_url`) with kwargs to the `options` function before calling delay. 305 | 306 | Example: 307 | 308 | ```python 309 | # Trigger after 2 minutes 310 | simple_task.options(countdown=120).delay() 311 | ``` 312 | 313 | ### ScheduledRouteBuilder 314 | 315 | Usage: 316 | 317 | ```python 318 | ScheduledRoute = ScheduledRouteBuilder(...) 319 | scheduled_router = APIRouter(route_class=ScheduledRoute) 320 | 321 | @scheduled_router.get("/simple_scheduled_task") 322 | def simple_scheduled_task(): 323 | return {} 324 | 325 | 326 | simple_scheduled_task.scheduler(name="simple_scheduled_task", schedule="* * * * *").schedule() 327 | ``` 328 | 329 | 330 | ## Hooks 331 | 332 | We might need to override things in the task being sent to Cloud Tasks. The `pre_create_hook` allows us to do that. 333 | 334 | Some hooks are included in the library. 335 | 336 | - `oidc_delayed_hook` / `oidc_scheduled_hook` - Used to pass OIDC token (for Cloud Run etc). 337 | - `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) 338 | - `chained_hook` - If you need to chain multiple hooks together, you can do that with `chained_hook(hook1, hook2)` 339 | 340 | ## Helper dependencies 341 | 342 | ### max_retries 343 | 344 | ```python 345 | @delayed_router.post("/fail_twice", dependencies=[Depends(max_retries(2))]) 346 | async def fail_twice(): 347 | raise Exception("nooo") 348 | ``` 349 | 350 | ### CloudTasksHeaders 351 | 352 | ```python 353 | @delayed_router.get("/my_task") 354 | async def my_task(ct_headers: CloudTasksHeaders = Depends()): 355 | print(ct_headers.queue_name) 356 | ``` 357 | 358 | Check the file [fastapi_cloud_tasks/dependencies.py](fastapi_cloud_tasks/dependencies.py) for details. 359 | 360 | ## Contributing 361 | 362 | - Run `pre-commit install` on your local to get pre-commit hook. 363 | - Make changes and raise a PR! 364 | - If the change is massive, open an issue to discuss it before writing code. 365 | 366 | Note: This project is neither affiliated with, nor sponsored by Google. --------------------------------------------------------------------------------