Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Coming from FastAPI

Routes stay. Work that must finish even if the process dies (a charge, a provisioning job, anything handed to BackgroundTasks) moves into a Pyrula @workflow, and the route submits a run and returns its id. RunRouter serves run status, a server-sent events stream and control endpoints. A worker executes the runs: in the app’s lifespan in development, as its own process in production.

Terminal window
pyrula init --fastapi
python app.py # starts one checkout in-process and prints the result
uvicorn app:app # serves the app and the run endpoints

See Start from a scaffold.

FastAPIPyrulaChange
BackgroundTasks.add_taskawait client.submit(name, params) of a @workflow; the run survives a restartSmall rewrite
Handler with several outside callsA @workflow with each call in a @stepSmall rewrite
Handler returning the resultReturn {"run_id": ...}; read the result from the run endpoints or a RunHandleConceptual redesign
app.include_routerRunRouter(...).attach(app, prefix="/runs")Direct swap
Lifespan starting background workWorkflowWorker in the lifespan for development; pyrula worker in productionOperational change
Idempotency-Key headersubmit(idempotency_key=...); a retried POST gets the first runDirect swap
Depends(...) authYours for your routes; run endpoints take AuthConfig(provider=APIKeyAuth(...)) or JWTAuth (Authentication)Small rewrite
WebSocket or SSE progressGET /runs/{name}/stream/{run_id}; resumes from Last-Event-IDDirect swap
Polling endpointGET /runs/{name}/runs/{run_id}Direct swap
Cancel endpointDELETE /runs/{name}/stream/{run_id}; POST .../terminate ends it nowDirect swap
Callback route for a human decisionctx.interrupt(...) answered on POST .../resume, or ctx.wait_for_signal(...) on POST .../signalConceptual redesign
asyncio.sleep in a background taskawait ctx.sleep(...), a durable timerDirect swap
asyncio.gatherawait ctx.gather(...), each leg journaledDirect swap
Retry decorators@step(retry=RetryPolicy(...))Direct swap
Request timeout@step(timeout=) per try; @workflow(timeout=) per attempt (300 s)Small rewrite
Rate-limit middlewareYours for requests; @workflow(rate_limit=Rate(...)), concurrency=Limit(...) limit claimsOperational change
429 or 503 load sheddingRunRouterConfig(max_queue_depth=...) refuses a submit with Retry-AfterDirect swap
Sync def routeThe blocking pyrula.workflows.sync.WorkflowClientSmall rewrite
uvicorn --workers NSize API and worker processes separately; WorkerOptions(max_concurrent_runs=)Operational change
Admin page, log searchmount_run_ui(app, prefix="/ui", control=...), pyrula dev, Pyrula CloudOperational change
Error text in responsesReplaced unless RunRouterConfig(expose_error_details=True)Operational change
OpenTelemetry instrumentationWorkflow spans and worker metrics (Observability)Direct swap
TestClientUnchanged for routes; WorkflowTestEnv for workflow logicDirect swap
An agent APIcreate_app(...) from pyrula.agents (HTTP)Small rewrite

The scaffold’s route, driven through TestClient. A retried POST gets the same run, and the charge happens once:

import asyncio
import tempfile
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pyrula.contracts.web import AuthConfig
from pyrula.workflows import (
WorkerOptions,
WorkflowContext,
WorkflowWorker,
open_store,
step,
workflow,
)
from pyrula.workflows.web import RunRouter, RunRouterConfig, StoreControl
charges: list[str] = []
@step
def charge_card(order_id: str) -> dict:
charges.append(order_id)
return {"order_id": order_id, "charged": 4200}
# What used to be a BackgroundTasks function.
@workflow(name="docs_from_fastapi_checkout")
async def checkout(ctx: WorkflowContext, order_id: str) -> dict:
return await charge_card(order_id)
# A fresh file per run of this example; an app keeps one store path for good.
store = open_store(default=f"sqlite:///{tempfile.mkdtemp()}/checkout.db")
worker = WorkflowWorker(
workflows=[checkout],
store=store,
options=WorkerOptions(claim_poll_interval=0.05, timer_poll_interval_s=0.1),
)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(worker.run()) # development: the worker in the app's process
try:
yield
finally:
worker.stop()
await task
app = FastAPI(lifespan=lifespan)
# provider=None switches authentication off, for local work only.
RunRouter(
control=StoreControl(store),
config=RunRouterConfig(auth_config=AuthConfig(provider=None)),
workflows=[checkout],
).attach(app, prefix="/runs")
@app.post("/orders/{order_id}/checkout", status_code=202)
async def start_checkout(order_id: str) -> dict:
run = await worker.client.submit(
"docs_from_fastapi_checkout",
{"order_id": order_id},
idempotency_key=f"checkout:{order_id}",
)
return {"run_id": run.run_id}
with TestClient(app) as http:
first = http.post("/orders/A-1/checkout").json()
assert http.post("/orders/A-1/checkout").json() == first # a retried POST
status_url = f"/runs/docs_from_fastapi_checkout/runs/{first['run_id']}"
deadline = time.monotonic() + 30
while http.get(status_url).json()["status"] != "complete":
assert time.monotonic() < deadline
time.sleep(0.1)
assert charges == ["A-1"]

View this page as Markdown