feat: retry a workflow-as-code task from its task options (#11013)

* feat: retry a workflow-as-code task from its task options

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvjgKMRNtRNPnAkg6MkiTA

* fix: claim every retry attempt key up front, so a step cannot alias one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvjgKMRNtRNPnAkg6MkiTA

* fix: bound retry attempts, which now claim their keys up front

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvjgKMRNtRNPnAkg6MkiTA

* fix: honour an explicit zero retry multiplier in the python client

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvjgKMRNtRNPnAkg6MkiTA

* docs: state the retry validation rules once in the task docstring

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvjgKMRNtRNPnAkg6MkiTA

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-08 11:37:56 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 33f9828c3e
commit d3f305db98
15 changed files with 1126 additions and 106 deletions
+84 -7
View File
@@ -4580,6 +4580,22 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
# string, a tuple as a list.
#
# \`\`retry\`\` re-dispatches the task after a failure, inside \`\`@workflow\`\` only.
# Every attempt is a step of its own (\`\`call_api\`\`, \`\`call_api#2\`\`, ...) and
# the wait between two of them is a durable sleep, so a retrying task holds no
# worker while it backs off. Keys: \`\`attempts\`\` (retries after the first
# failure, a whole number from 0 to 100), \`\`delay\`\` (seconds before the first
# retry, sub-second delays dropped), \`\`multiplier\`\` (applied to the delay
# after each attempt, 1 keeps it constant), \`\`max_delay\`\` (ceiling in
# seconds). \`\`attempts\`\` is required, and an out-of-range or unknown key is
# rejected where the policy is written.
#
# A workflow sleeps once per round, so tasks backing off in the same fan-out
# wait one after another rather than together: the delay before a fan-out
# retries is the sum of every backoff pending in it, not the longest one, and
# it grows with both the width of the fan-out and \`\`attempts\`\`. Retries with
# no \`\`delay\`\` all go out in a single round.
#
# Usage::
#
# @task
@@ -4587,10 +4603,15 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
#
# @task(retry={"attempts": 3, "delay": 30, "multiplier": 2})
# async def call_api(payload: dict): ...
def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
# Create a task that dispatches to a separate Windmill script.
#
# \`\`retry\`\` takes the same policy as :func:\`task\`.
#
# Usage::
#
# extract = task_script("f/data/extract", timeout=600)
@@ -4598,10 +4619,12 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
# Create a task that dispatches to a separate Windmill flow.
#
# \`\`retry\`\` takes the same policy as :func:\`task\`.
#
# Usage::
#
# pipeline = task_flow("f/etl/pipeline", priority=10)
@@ -4609,7 +4632,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#
@@ -6657,6 +6680,34 @@ A caught failure reads the same whether it came from a task or from a \`step()\`
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"\`
\`\`\`typescript
/**
* Re-dispatch policy for a failed task.
*
* Every attempt is a step of its own (\`fetch\`, \`fetch#2\`, \`fetch#3\`), and the
* wait between two of them is a durable sleep, so a retrying task holds no
* worker while it backs off.
*
* A workflow sleeps once per round, so tasks backing off in the same fan-out
* wait one after another rather than together: the delay before a fan-out
* retries is the sum of every backoff pending in it, not the longest one, and
* it grows with both the width of the fan-out and \`attempts\`. Retries with no
* \`delay\` all go out in a single round.
*/
export interface TaskRetry {
/** Attempts after the first failure: \`2\` runs the task at most 3 times.
* A whole number from 0 to 100; anything else is rejected where the policy
* is written. */
attempts: number;
/** Seconds to wait before the first retry. Default 0, retry immediately.
* Sub-second delays are dropped a durable sleep resolves to the second. */
delay?: number;
/** Applied to the delay after each attempt: 1 (the default) keeps it
* constant, 2 doubles it. */
multiplier?: number;
/** Ceiling for the delay in seconds, for a \`multiplier\` above 1. */
max_delay?: number;
}
export interface TaskOptions {
timeout?: number;
tag?: string;
@@ -6665,6 +6716,7 @@ export interface TaskOptions {
concurrency_limit?: number;
concurrency_key?: string;
concurrency_time_window_s?: number;
retry?: TaskRetry;
}
/**
@@ -6682,9 +6734,11 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro
* @example
* const extract_data = task(async (url: string) => { ... });
* const run_external = task("f/external_script", async (x: number) => { ... });
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a \`workflow()\`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} retry included does not apply.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a \`Date\` comes back as a string, a
@@ -6828,6 +6882,22 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
# string, a tuple as a list.
#
# \`\`retry\`\` re-dispatches the task after a failure, inside \`\`@workflow\`\` only.
# Every attempt is a step of its own (\`\`call_api\`\`, \`\`call_api#2\`\`, ...) and
# the wait between two of them is a durable sleep, so a retrying task holds no
# worker while it backs off. Keys: \`\`attempts\`\` (retries after the first
# failure, a whole number from 0 to 100), \`\`delay\`\` (seconds before the first
# retry, sub-second delays dropped), \`\`multiplier\`\` (applied to the delay
# after each attempt, 1 keeps it constant), \`\`max_delay\`\` (ceiling in
# seconds). \`\`attempts\`\` is required, and an out-of-range or unknown key is
# rejected where the policy is written.
#
# A workflow sleeps once per round, so tasks backing off in the same fan-out
# wait one after another rather than together: the delay before a fan-out
# retries is the sum of every backoff pending in it, not the longest one, and
# it grows with both the width of the fan-out and \`\`attempts\`\`. Retries with
# no \`\`delay\`\` all go out in a single round.
#
# Usage::
#
# @task
@@ -6835,10 +6905,15 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
#
# @task(retry={"attempts": 3, "delay": 30, "multiplier": 2})
# async def call_api(payload: dict): ...
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
# Create a task that dispatches to a separate Windmill script.
#
# \`\`retry\`\` takes the same policy as :func:\`task\`.
#
# Usage::
#
# extract = task_script("f/data/extract", timeout=600)
@@ -6846,10 +6921,12 @@ def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None,
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
# Create a task that dispatches to a separate Windmill flow.
#
# \`\`retry\`\` takes the same policy as :func:\`task\`.
#
# Usage::
#
# pipeline = task_flow("f/etl/pipeline", priority=10)
@@ -6857,7 +6934,7 @@ def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str]
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#