Using Synchronous Code¶
This SDK requires executable workflow bodies such as durable handlers, steps,
and flow nodes to be async def, but your business code does not have to be
fully async. You can call existing synchronous functions from async durable
callables as long as you preserve the durable execution replay rules.
Declarative and configuration hooks are intentionally synchronous. For example,
a @durable_dag definition must be a regular def because it only declares
and validates a flow graph, while its @durable_node bodies must be
async def.
The important distinction is where the synchronous code runs:
- Deterministic, side-effect-free synchronous code may run directly in the handler.
- Nondeterministic synchronous code must run inside a durable step.
- Blocking synchronous I/O should usually run in a worker thread with
asyncio.to_thread().
What Must Be Async¶
The SDK validates user-provided durable callables. These entry points must be asynchronous:
@durable_executionhandlers@durable_callablestep functions@durable_nodeflow node functions- child context functions passed to
run_in_child_context() - callback submitters passed to
wait_for_callback() - item functions passed to
map() - branch callables passed to
parallel() - condition checks passed to
wait_for_condition()
These entry points can be functions, instance methods, class methods, or static methods. Durable operations are awaitable and run on the same event loop as the handler.
Do not pass a regular def function directly to step(), run_in_child_context(),
node(), map(), or parallel(). Wrap synchronous work in an async durable
callable instead.
What Must Stay Synchronous¶
These declarative and configuration hooks must be regular synchronous callables,
not async def functions:
@durable_dagdefinitions- retry and polling strategies
- custom completion callbacks passed to
CompletionConfig.custom() item_namercallbacks passed tomap()summary_generatorcallbacks
Keep DAG definitions and structural or metadata hooks deterministic and side-effect free. They must not perform I/O, start durable operations, or return awaitables.
Calling Pure Synchronous Helpers¶
Pure deterministic helpers can be called directly from your async handler. This is safe when the function returns the same output for the same input and has no side effects.
from async_durable_execution import durable_callable, durable_execution, step
def normalize_order_id(raw_order_id: str) -> str:
return raw_order_id.strip().upper()
@durable_callable
async def create_receipt(order_id: str) -> dict:
return {"receipt_id": f"receipt-{order_id}"}
@durable_execution
async def handler(event: dict) -> dict:
order_id = normalize_order_id(event["order_id"])
receipt = await step(create_receipt(order_id), name="create-receipt")
return {"order_id": order_id, "receipt": receipt}
Do not call synchronous helpers directly from the handler if they read clocks, generate random values, make network calls, query databases, write files, send messages, or otherwise depend on external state. Those operations can repeat on replay unless they are inside a step.
Wrapping Blocking I/O in a Step¶
For existing synchronous clients such as requests, boto3, database drivers, or
legacy SDKs, keep the side effect inside a step and run the blocking call through
asyncio.to_thread().
import asyncio
import requests
from async_durable_execution import durable_callable, durable_execution, step
def fetch_customer_sync(customer_id: str) -> dict:
response = requests.get(
f"https://example.com/customers/{customer_id}",
timeout=10,
)
response.raise_for_status()
return response.json()
@durable_callable
async def fetch_customer(customer_id: str) -> dict:
return await asyncio.to_thread(fetch_customer_sync, customer_id)
@durable_execution
async def handler(event: dict) -> dict:
customer = await step(
fetch_customer(event["customer_id"]),
name="fetch-customer",
)
return {"customer": customer}
asyncio.to_thread() prevents the blocking call from stopping the event loop while the
step is running. The durable step still controls checkpointing, retries, and replay.
Calling Fast Synchronous Code in a Step¶
If the synchronous function is CPU-light and does not block on I/O, you can call it directly inside the async step wrapper.
from async_durable_execution import durable_callable, step
def calculate_tax_sync(order: dict) -> dict:
return {"amount": round(order["subtotal"] * 0.0825, 2)}
@durable_callable
async def calculate_tax(order: dict) -> dict:
return calculate_tax_sync(order)
tax = await step(calculate_tax(order), name="calculate-tax")
For expensive CPU-bound work, consider moving it to another service or Lambda function and invoking it durably. Worker threads avoid blocking the event loop, but they do not make CPU-heavy Python code parallel under the GIL.
Synchronous Side Effects¶
Treat synchronous side effects the same way you treat async side effects: put them in steps and make them idempotent where possible.
import asyncio
import smtplib
from email.message import EmailMessage
from async_durable_execution import durable_callable, durable_execution, step
def send_email_sync(to_address: str, subject: str, body: str) -> None:
message = EmailMessage()
message["To"] = to_address
message["Subject"] = subject
message.set_content(body)
with smtplib.SMTP("smtp.example.com", timeout=10) as client:
client.send_message(message)
@durable_callable
async def send_email(to_address: str, subject: str, body: str) -> None:
await asyncio.to_thread(send_email_sync, to_address, subject, body)
@durable_execution
async def handler(event: dict) -> dict:
await step(
send_email(
event["to"],
"Order received",
f"Order {event['order_id']} was received.",
),
name="send-email",
)
return {"status": "sent"}
For non-idempotent side effects, use StepSemantics.AT_MOST_ONCE_PER_RETRY when
appropriate, and use application-level idempotency keys when the downstream system
supports them.
Synchronous Callback Submitters¶
wait_for_callback() submitters must be async, but the notification code they call can
be synchronous. Keep the external notification inside the submitter and use
asyncio.to_thread() if it blocks.
import asyncio
from datetime import timedelta
from async_durable_execution import (
durable_callable,
durable_execution,
get_wait_for_callback_context,
wait_for_callback,
)
def submit_approval_sync(callback_id: str, approver_email: str) -> None:
send_approval_email(approver_email, callback_id)
@durable_callable
async def submit_approval(approver_email: str) -> None:
callback_context = get_wait_for_callback_context()
await asyncio.to_thread(
submit_approval_sync,
callback_context.callback_id,
approver_email,
)
@durable_execution
async def handler(event: dict) -> dict:
approval = await wait_for_callback(
submit_approval(event["approver_email"]),
timeout=timedelta(hours=24),
name="approval",
)
return {"approval": approval}
Avoid Nested Event Loops¶
Do not call asyncio.run() inside a durable handler or durable callable. The
@durable_execution wrapper owns the event loop for the invocation.
If you have an async library, await it normally from your async durable callable. If you
have a synchronous library, call it directly or use asyncio.to_thread() depending on
whether it blocks.
Checklist¶
- Keep executable workflow bodies as
async def. - Keep declarative and configuration hooks synchronous; keep DAG definitions and structural or metadata hooks deterministic and side-effect free.
- Call deterministic synchronous helpers directly only when they have no side effects.
- Put synchronous I/O, external reads, and writes inside
@durable_callablesteps. - Use
asyncio.to_thread()for blocking synchronous I/O. - Do not perform durable operations from inside a step.
- Do not call
asyncio.run()from durable code. - Name the step that wraps each important synchronous operation so tests and logs stay clear.