Skip to content

Commit 82ecad6

Browse files
andystaplesCopilot
andauthored
Add opt-in durable HTTP failure retries (#338)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent b27a325 commit 82ecad6

7 files changed

Lines changed: 600 additions & 19 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
ADDED
11+
12+
- Added opt-in `HttpRetryOptions` on `context.call_http()` for durable initial
13+
request retries with configurable timing and status selection. Empty or omitted
14+
status lists select all non-2xx responses; exhausted retries fail the call.
15+
Subsequent 202 Location polls do not inherit the policy. See
16+
[usage and upgrade guidance](README.md#durable-http-failure-retries).
17+
Release coordination requires the corrected core replay-safe retry-timeout
18+
behavior before this provider feature is published; version and dependency
19+
minimum updates belong in the dedicated release PR.
20+
1021
FIXED
1122

1223
- With the corresponding core SDK update, activity and sub-orchestration

‎azure-functions-durable/README.md‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,79 @@ their existing page sizes.
4848
so they can be used as dictionary keys or set elements. Construct a new ID
4949
instead of assigning or deleting attributes on an existing ID.
5050

51+
## Durable HTTP failure retries
52+
53+
Opt in to durable retries for the **initial** `context.call_http()` request with
54+
`HttpRetryOptions`. It uses the core activity retry engine and durable timers,
55+
not an in-process HTTP retry loop:
56+
57+
```python
58+
from datetime import timedelta
59+
60+
import azure.durable_functions as df
61+
62+
63+
def orchestrator(context: df.DurableOrchestrationContext):
64+
response = yield context.call_http(
65+
"GET",
66+
"https://example.com/data",
67+
retry_options=df.HttpRetryOptions(
68+
first_retry_interval=timedelta(seconds=2),
69+
max_number_of_attempts=4,
70+
backoff_coefficient=2,
71+
max_retry_interval=timedelta(seconds=30),
72+
retry_timeout=timedelta(minutes=2),
73+
status_codes_to_retry=[429, 503],
74+
),
75+
)
76+
return response.content
77+
```
78+
79+
Durations use `timedelta`, matching `RetryPolicy`, rather than the deprecated
80+
millisecond-based `RetryOptions`. The attempt limit includes the initial
81+
attempt. Backoff defaults to 1 (constant delay), the maximum retry interval
82+
defaults to six days, and the retry timeout defaults to unbounded. Set
83+
`max_retry_interval=None` to remove the interval cap.
84+
85+
With a policy, an omitted, `None`, or empty `status_codes_to_retry` selects
86+
**all non-2xx responses**, including unfollowed redirects, matching .NET
87+
`EnsureSuccessStatusCode` semantics, not just 4xx/5xx. A nonempty list selects
88+
exactly those statuses: selecting 202 retries it instead of starting polling.
89+
Excluded statuses return the full `DurableHttpResponse` unchanged. Selected
90+
responses are closed without reading their bodies and become activity
91+
failures; exhaustion fails `call_http` with `durabletask.task.TaskFailedError`
92+
rather than returning the last selected response. Transport and other activity
93+
failures also use the policy regardless of the status list.
94+
95+
The policy and status list are snapshotted when the request is scheduled.
96+
Omitting `retry_options` preserves the existing request payload and behavior.
97+
Automatic 202/Location polling remains separate: subsequent GET polls inherit
98+
neither the failure retry policy nor its status classification. Poll HTTP
99+
errors return normally, and a poll activity/transport failure fails the call
100+
without these retries. `Retry-After` controls polling delays, not failure retry
101+
delays.
102+
103+
`retry_timeout` uses replay-safe orchestration time from the initial activity
104+
scheduling. It stops retrying when a failure is handled at or beyond the
105+
timeout. It does **not** cancel or time-limit an in-flight HTTP request, and
106+
the next retry delay may cross the timeout. Unlike the JavaScript core engine,
107+
Python does not reject a retry solely because its next delay would exceed the
108+
remaining timeout.
109+
110+
> [!WARNING]
111+
> Repeating requests can duplicate side effects, including when a server
112+
> processes a request but its response is lost. Enable retries only for safe
113+
> operations or use server-enforced idempotency keys. Adding or changing retry
114+
> options changes durable scheduling and failure handling. Keep policies
115+
> deterministic; version new caller orchestrations or drain affected in-flight
116+
> instances on the old code before changing their policy.
117+
118+
This feature requires the corrected core replay-safe retry-timeout behavior in
119+
[#326](https://github.com/microsoft/durabletask-python/pull/326). Release the core
120+
fix before publishing this provider feature, then coordinate the provider
121+
version and core dependency minimum in a dedicated release PR. This feature
122+
does not itself change package versions or dependency minimums.
123+
51124
## Unicode JSON serialization
52125

53126
By default, durable payloads preserve the shared `azure-functions` codec's

‎azure-functions-durable/azure/durable_functions/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from .decorators.durable_app import Blueprint, DFApp
1010
from .client import DurableFunctionsClient, SyncDurableFunctionsClient
11-
from .http.models import DurableHttpRequest, DurableHttpResponse
11+
from .http.models import DurableHttpRequest, DurableHttpResponse, HttpRetryOptions
1212
from .orchestrator import Orchestrator
1313
from .internal.compat.retry_options import RetryOptions
1414
from .internal.compat.orchestration_runtime_status import OrchestrationRuntimeStatus
@@ -51,6 +51,7 @@
5151
"Entity",
5252
"EntityId",
5353
"EntityStateResponse",
54+
"HttpRetryOptions",
5455
"ManagedIdentityTokenSource",
5556
"Orchestrator",
5657
"OrchestrationRuntimeStatus",

‎azure-functions-durable/azure/durable_functions/http/builtin.py‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from typing import Any, Generator, Optional
3535
from urllib.parse import urljoin, urlparse
3636

37-
from .models import DurableHttpResponse
37+
from .models import DurableHttpResponse, HttpRetryOptions
3838

3939
# Reserved built-in function names. The v1 host used ``BuiltIn::HttpActivity``;
4040
# ``::`` is not a valid Azure Functions function name, so ``__`` is used here.
@@ -163,8 +163,10 @@ def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
163163
164164
``input`` is the JSON form of a
165165
:class:`~azure.durable_functions.http.models.DurableHttpRequest`
166-
(``method``, ``uri``, ``content``, ``headers``, ``tokenSource``). The
167-
return value is the JSON form of a
166+
(``method``, ``uri``, ``content``, ``headers``, ``tokenSource``,
167+
``retryOptions``). Selected response statuses raise an activity failure
168+
before buffering the body, allowing the core durable retry policy to run.
169+
The return value is the JSON form of a
168170
:class:`~azure.durable_functions.http.models.DurableHttpResponse`.
169171
170172
The parameter and return are declared as ``dict[str, Any]`` for static type
@@ -204,16 +206,24 @@ def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
204206
req = urllib.request.Request(url=uri, data=data, method=method, headers=headers)
205207

206208
try:
207-
with _open_http_request(req) as resp:
208-
status = int(resp.status)
209-
resp_headers = {k: v for k, v in resp.headers.items()}
210-
body = resp.read().decode("utf-8", errors="replace")
209+
response = _open_http_request(req)
211210
except urllib.error.HTTPError as e:
212-
# Non-2xx responses (including 202) surface here; capture rather than raise
213-
# so the orchestrator can inspect the status code and headers.
214-
status = int(e.code)
215-
resp_headers = {k: v for k, v in (e.headers or {}).items()}
216-
body = e.read().decode("utf-8", errors="replace")
211+
# urllib raises for HTTP errors and unfollowed redirects. Treat these
212+
# like ordinary responses so excluded statuses still return their body.
213+
response = e
214+
215+
with response as resp:
216+
status = int(resp.code if isinstance(resp, urllib.error.HTTPError) else resp.status)
217+
retry_options = request.get("retryOptions")
218+
if retry_options is not None:
219+
codes = retry_options.get("statusCodesToRetry")
220+
selected = status in codes if codes else not 200 <= status < 300
221+
if selected:
222+
# Leaving the context closes the response without reading a
223+
# potentially unbounded body before the durable retry begins.
224+
raise RuntimeError(f"HTTP request failed with status code {status}.")
225+
resp_headers = {k: v for k, v in (resp.headers or {}).items()}
226+
body = resp.read().decode("utf-8", errors="replace")
217227

218228
return DurableHttpResponse(
219229
status_code=status, headers=resp_headers, content=body).to_json()
@@ -287,8 +297,13 @@ def builtin_http_poll_orchestrator(context: Any) -> Generator[Any, Any, DurableH
287297
"sub-orchestration.")
288298

289299
request: dict[str, Any] = context.get_input() or {}
290-
response: dict[str, Any] = yield context.call_activity(
291-
BUILTIN_HTTP_ACTIVITY_NAME, request)
300+
retry_options = request.get("retryOptions")
301+
if retry_options is None:
302+
initial_task = context.call_activity(BUILTIN_HTTP_ACTIVITY_NAME, request)
303+
else:
304+
initial_task = context.call_activity_with_retry(
305+
BUILTIN_HTTP_ACTIVITY_NAME, HttpRetryOptions.from_json(retry_options), request)
306+
response: dict[str, Any] = yield initial_task
292307

293308
# Track the URI of the most recent request so a relative ``Location`` can be
294309
# resolved against it.

‎azure-functions-durable/azure/durable_functions/http/models.py‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,76 @@
1212

1313
from __future__ import annotations
1414

15+
from datetime import timedelta
1516
from typing import Any, Optional, cast
1617

18+
from durabletask.task import RetryPolicy
19+
1720
from ..internal.compat.token_source import TokenSource
1821

1922

23+
class HttpRetryOptions(RetryPolicy):
24+
"""Durable failure retries for the initial HTTP request, not Location polls.
25+
26+
Durations use ``timedelta``, as in :class:`durabletask.task.RetryPolicy`.
27+
``max_number_of_attempts`` includes the initial attempt. Backoff defaults to
28+
1, the maximum retry interval to six days, and the retry timeout to unbounded.
29+
An omitted or empty ``status_codes_to_retry`` selects every non-2xx
30+
response; a nonempty list selects exactly those codes (even 202).
31+
Transport and other activity failures are retried regardless of this list.
32+
Exhaustion fails ``call_http`` rather than returning a selected response.
33+
"""
34+
35+
def __init__(self, *,
36+
first_retry_interval: timedelta,
37+
max_number_of_attempts: int,
38+
backoff_coefficient: float | None = 1.0,
39+
max_retry_interval: timedelta | None = timedelta(days=6),
40+
retry_timeout: timedelta | None = None,
41+
status_codes_to_retry: Optional[list[int]] = None) -> None:
42+
super().__init__(
43+
first_retry_interval=first_retry_interval,
44+
max_number_of_attempts=max_number_of_attempts,
45+
backoff_coefficient=backoff_coefficient,
46+
max_retry_interval=max_retry_interval,
47+
retry_timeout=retry_timeout)
48+
self._status_codes_to_retry = (
49+
list(status_codes_to_retry) if status_codes_to_retry is not None else None)
50+
51+
@property
52+
def status_codes_to_retry(self) -> Optional[list[int]]:
53+
"""Return a copy of the selected response codes, or ``None``."""
54+
return (list(self._status_codes_to_retry)
55+
if self._status_codes_to_retry is not None else None)
56+
57+
def to_json(self) -> dict[str, Any]:
58+
"""Snapshot the policy into a JSON-serializable wire payload."""
59+
millisecond = timedelta(milliseconds=1)
60+
return {
61+
"firstRetryIntervalInMilliseconds": self.first_retry_interval / millisecond,
62+
"maxNumberOfAttempts": self.max_number_of_attempts,
63+
"backoffCoefficient": self.backoff_coefficient,
64+
"maxRetryIntervalInMilliseconds": (
65+
self.max_retry_interval / millisecond if self.max_retry_interval is not None else None),
66+
"retryTimeoutInMilliseconds": (
67+
self.retry_timeout / millisecond if self.retry_timeout is not None else None),
68+
"statusCodesToRetry": self.status_codes_to_retry,
69+
}
70+
71+
@classmethod
72+
def from_json(cls, value: dict[str, Any]) -> "HttpRetryOptions":
73+
"""Reconstruct a snapshotted policy from its wire payload."""
74+
max_interval = value["maxRetryIntervalInMilliseconds"]
75+
timeout = value["retryTimeoutInMilliseconds"]
76+
return cls(
77+
first_retry_interval=timedelta(milliseconds=value["firstRetryIntervalInMilliseconds"]),
78+
max_number_of_attempts=value["maxNumberOfAttempts"],
79+
backoff_coefficient=value["backoffCoefficient"],
80+
max_retry_interval=timedelta(milliseconds=max_interval) if max_interval is not None else None,
81+
retry_timeout=timedelta(milliseconds=timeout) if timeout is not None else None,
82+
status_codes_to_retry=value.get("statusCodesToRetry"))
83+
84+
2085
class DurableHttpRequest:
2186
"""Data structure representing a durable HTTP request."""
2287

@@ -25,12 +90,14 @@ def __init__(self,
2590
uri: str,
2691
content: Optional[str] = None,
2792
headers: Optional[dict[str, str]] = None,
28-
token_source: Optional[TokenSource] = None):
93+
token_source: Optional[TokenSource] = None,
94+
retry_options: Optional[HttpRetryOptions] = None):
2995
self._method = method
3096
self._uri = uri
3197
self._content = content
3298
self._headers = headers
3399
self._token_source = token_source
100+
self._retry_options = retry_options.to_json() if retry_options is not None else None
34101

35102
@property
36103
def method(self) -> str:
@@ -57,6 +124,12 @@ def token_source(self) -> Optional[TokenSource]:
57124
"""Get the source of the OAuth token to add to the request."""
58125
return self._token_source
59126

127+
@property
128+
def retry_options(self) -> Optional[HttpRetryOptions]:
129+
"""Get a copy of the policy captured when this request was created."""
130+
return (HttpRetryOptions.from_json(self._retry_options)
131+
if self._retry_options is not None else None)
132+
60133
def to_json(self) -> dict[str, Any]:
61134
"""Convert this request into a JSON-serializable dictionary."""
62135
json_dict: dict[str, Any] = {"method": self._method, "uri": self._uri}
@@ -66,6 +139,9 @@ def to_json(self) -> dict[str, Any]:
66139
json_dict["headers"] = dict(self._headers)
67140
if self._token_source is not None:
68141
json_dict["tokenSource"] = self._token_source.to_json()
142+
retry_options = self.retry_options
143+
if retry_options is not None:
144+
json_dict["retryOptions"] = retry_options.to_json()
69145
return json_dict
70146

71147

‎azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import inspect
55
import json
66
from datetime import UTC, datetime
7-
from typing import Any, Callable, Generator, Optional, cast
7+
from typing import TYPE_CHECKING, Any, Callable, Generator, Optional, cast
88
from uuid import UUID
99

1010
from durabletask import task
@@ -15,6 +15,9 @@
1515
from .function_context import FunctionContext
1616
from .token_source import TokenSource
1717

18+
if TYPE_CHECKING:
19+
from ...http.models import HttpRetryOptions
20+
1821

1922
class DurableOrchestrationContext:
2023
"""Azure Functions-style orchestration context (v1-compatible).
@@ -215,7 +218,8 @@ def call_http(self,
215218
content: Any = None,
216219
headers: Optional[dict[str, str]] = None,
217220
token_source: Optional[TokenSource] = None,
218-
is_raw_str: bool = False) -> Task[Any]:
221+
is_raw_str: bool = False,
222+
retry_options: Optional["HttpRetryOptions"] = None) -> Task[Any]:
219223
"""Schedule a durable HTTP call to the specified endpoint.
220224
221225
The request is executed by a built-in activity and, if the endpoint
@@ -240,6 +244,11 @@ def call_http(self,
240244
is_raw_str: bool
241245
When ``True``, send ``str`` content as-is instead of JSON-encoding
242246
it. Requires ``content`` to be a ``str``.
247+
retry_options: Optional[HttpRetryOptions]
248+
Opt-in durable failure retries for the initial request only.
249+
Selected HTTP statuses and activity failures fail this task after
250+
exhaustion. Location polls do not inherit the policy. Only enable
251+
retries when repeating the request's side effects is safe.
243252
"""
244253
# Imported lazily to avoid a module import cycle
245254
# (http.models -> compat.token_source).
@@ -260,7 +269,8 @@ def call_http(self,
260269
else:
261270
json_content = json.dumps(content)
262271

263-
request = DurableHttpRequest(method, uri, json_content, headers, token_source)
272+
request = DurableHttpRequest(
273+
method, uri, json_content, headers, token_source, retry_options)
264274
return self._ctx.call_sub_orchestrator(
265275
BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME,
266276
input=request.to_json(),

0 commit comments

Comments
 (0)