Calling external services
Structure tasks that call HTTP APIs or RPC endpoints with timeouts, retries, and fallbacks to handle network failures gracefully.
Most production workflows reach outside their process: a REST API, a payment gateway, a third-party data feed. Those calls can be slow, intermittently unavailable, or silently wrong. Wrapping them in a Flux task gives you durability. The result is recorded the first time it succeeds, so a worker restart or workflow replay never re-issues the call. timeout, retry_max_attempts, and fallback cover the remaining failure modes.
The baseline: a simple HTTP task
The github_stars example in the Flux repository is the canonical starting point:
import httpx
from flux import ExecutionContext
from flux.task import task
from flux.workflow import workflow
@task
async def get_stars(repo: str) -> int:
url = f"https://api.github.com/repos/{repo}"
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
return response.json()["stargazers_count"]
@workflow
async def repo_summary(ctx: ExecutionContext[str]):
return await get_stars(ctx.input)
if __name__ == "__main__":
ctx = repo_summary.run("edurdias/flux")
print(ctx.output) # {'stars': 2, ...}
print(ctx.has_succeeded) # True
This works, but it has no protection against a slow API or a transient failure. If the request hangs for thirty seconds, the workflow hangs too. If the API returns a 503, the workflow fails permanently.
Adding a timeout
Pass timeout=N to cap each attempt at N seconds. Flux wraps the call in asyncio.wait_for and raises ExecutionTimeoutError when the limit is exceeded:
@task.with_options(
timeout=10, # seconds per attempt; 0 (the default) means no limit
)
async def get_repo_info(repo: str) -> dict:
url = f"https://api.github.com/repos/{repo}"
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
data = response.json()
return {
"name": data["full_name"],
"stars": data["stargazers_count"],
"open_issues": data["open_issues_count"],
}
Adding retries
Transient errors — a momentary 429, a brief DNS hiccup — are worth retrying. Pass retry_max_attempts to tell Flux how many additional attempts to make after the first failure:
@task.with_options(
retry_max_attempts=3, # 3 retries = 4 total attempts
retry_delay=2, # wait 2 s before the first retry
retry_backoff=2, # exponential backoff multiplier: 2 s -> 4 s -> 8 s
timeout=10,
)
async def get_repo_info(repo: str) -> dict:
url = f"https://api.github.com/repos/{repo}"
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
return response.json()
retry_backoff produces genuine exponential backoff: each retry waits the previous delay multiplied by retry_backoff, capped at 600 s. With retry_delay=2 and retry_backoff=2 the waits are 2 s → 4 s → 8 s. This lets a transient outage clear without hammering the service. See Reliability: errors and retries for the full retry mechanics.
For retry basics and the complete options surface, see Defining tasks: task with retries. This page focuses on the external-service pattern specifically: what to do when retries are not enough.
Adding a fallback for unavailability
Sometimes an external service is down for minutes, not milliseconds. Retrying three times with a two-second delay does not help if the outage lasts an hour. Use fallback to return a safe substitute value instead of failing the workflow:
def repo_info_unavailable(repo: str) -> dict:
"""Return a sentinel value when the API is unreachable after all retries."""
return {
"name": repo,
"stars": None,
"open_issues": None,
"available": False,
}
@task.with_options(
retry_max_attempts=3,
retry_delay=2,
timeout=10,
fallback=repo_info_unavailable,
)
async def get_repo_info(repo: str) -> dict:
url = f"https://api.github.com/repos/{repo}"
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
data = response.json()
return {
"name": data["full_name"],
"stars": data["stargazers_count"],
"open_issues": data["open_issues_count"],
"available": True,
}
The fallback receives the same arguments as the primary function. Its return value is recorded as the task’s output and the workflow continues normally, with ctx.has_succeeded set to True. The fallback runs at most once and is not retried.
Complete example: HTTP task with timeout, retries, and fallback
The full pattern: caps each attempt, retries on transient failures, and falls back when the service is unavailable:
import httpx
from flux import ExecutionContext
from flux.task import task
from flux.workflow import workflow
def github_unavailable(owner: str, repo: str) -> dict:
return {
"full_name": f"{owner}/{repo}",
"stars": None,
"open_issues": None,
"available": False,
}
@task.with_options(
name="github-repo-{owner}-{repo}",
retry_max_attempts=3,
retry_delay=2,
timeout=10,
fallback=github_unavailable,
)
async def fetch_github_repo(owner: str, repo: str) -> dict:
url = f"https://api.github.com/repos/{owner}/{repo}"
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
data = response.json()
return {
"full_name": data["full_name"],
"stars": data["stargazers_count"],
"open_issues": data["open_issues_count"],
"available": True,
}
@workflow
async def audit_repos(ctx: ExecutionContext[list[list[str]]]):
results = []
for owner, repo in ctx.input:
info = await fetch_github_repo(owner, repo)
results.append(info)
return results
if __name__ == "__main__":
ctx = audit_repos.run([["edurdias", "flux"], ["python", "cpython"]])
for r in ctx.output:
status = "live" if r["available"] else "unavailable"
print(f"{r['full_name']}: {r['stars']} stars ({status})")
Idempotency
Flux records a task’s output after it succeeds and skips the call on replay. That guarantee covers the Flux side. If the external service is not idempotent (calling it twice has a different effect than calling it once), you carry that responsibility in the task.
Read-only calls such as GET requests and read queries are safe by nature. Write calls (POST, PUT, charges, email sends) need a client-side idempotency key derived from a stable identifier like the workflow execution ID. Avoid side effects in the fallback as well: the fallback is also recorded, and a fallback that mutates external state can leave inconsistencies.
See Reliability: idempotency for a full treatment.
Timeout enforcement
Flux enforces timeout via asyncio.wait_for. When the limit is exceeded, Flux raises ExecutionTimeoutError and feeds it into the normal retry/fallback/rollback chain. The counter resets per attempt: timeout=10, retry_max_attempts=3 can spend up to 40 seconds on calls alone before a fallback or failure.
See Reliability: timeouts for workflow-level timeouts and server-side enforcement.