From 8c17aee4cc82dcee05e8efa06469ff0f130ac565 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 11 Nov 2026 04:09:48 +0530 Subject: [PATCH 1/6] fix(http): handle missing optional fields in HttpRetryOptions.from_json Signed-off-by: Hardik Kaurani --- .../azure/durable_functions/http/models.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/azure-functions-durable/azure/durable_functions/http/models.py b/azure-functions-durable/azure/durable_functions/http/models.py index 59264e4c..cd9bda64 100644 --- a/azure-functions-durable/azure/durable_functions/http/models.py +++ b/azure-functions-durable/azure/durable_functions/http/models.py @@ -71,12 +71,17 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json(cls, value: dict[str, Any]) -> "HttpRetryOptions": """Reconstruct a snapshotted policy from its wire payload.""" - max_interval = value["maxRetryIntervalInMilliseconds"] - timeout = value["retryTimeoutInMilliseconds"] + max_interval = ( + value["maxRetryIntervalInMilliseconds"] + if "maxRetryIntervalInMilliseconds" in value + else 518400000 + ) + timeout = value.get("retryTimeoutInMilliseconds") + backoff = value.get("backoffCoefficient", 1.0) return cls( first_retry_interval=timedelta(milliseconds=value["firstRetryIntervalInMilliseconds"]), max_number_of_attempts=value["maxNumberOfAttempts"], - backoff_coefficient=value["backoffCoefficient"], + backoff_coefficient=backoff if backoff is not None else 1.0, max_retry_interval=timedelta(milliseconds=max_interval) if max_interval is not None else None, retry_timeout=timedelta(milliseconds=timeout) if timeout is not None else None, status_codes_to_retry=value.get("statusCodesToRetry")) From e48d4bac85e9a34abb8658f659613451b4baeafa Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 11 Nov 2026 04:17:46 +0530 Subject: [PATCH 2/6] fix(http): support snake_case keys in HttpRetryOptions.from_json Signed-off-by: Hardik Kaurani --- .../azure/durable_functions/http/models.py | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/azure-functions-durable/azure/durable_functions/http/models.py b/azure-functions-durable/azure/durable_functions/http/models.py index cd9bda64..3806ac0c 100644 --- a/azure-functions-durable/azure/durable_functions/http/models.py +++ b/azure-functions-durable/azure/durable_functions/http/models.py @@ -71,20 +71,43 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json(cls, value: dict[str, Any]) -> "HttpRetryOptions": """Reconstruct a snapshotted policy from its wire payload.""" + first_interval = ( + value["firstRetryIntervalInMilliseconds"] + if "firstRetryIntervalInMilliseconds" in value + else value.get("first_retry_interval_in_milliseconds", 0) + ) + max_attempts = ( + value["maxNumberOfAttempts"] + if "maxNumberOfAttempts" in value + else value.get("max_number_of_attempts", 1) + ) + backoff = ( + value["backoffCoefficient"] + if "backoffCoefficient" in value + else value.get("backoff_coefficient", 1.0) + ) max_interval = ( value["maxRetryIntervalInMilliseconds"] if "maxRetryIntervalInMilliseconds" in value - else 518400000 + else value.get("max_retry_interval_in_milliseconds", 518400000) + ) + timeout = ( + value["retryTimeoutInMilliseconds"] + if "retryTimeoutInMilliseconds" in value + else value.get("retry_timeout_in_milliseconds") + ) + status_codes = ( + value["statusCodesToRetry"] + if "statusCodesToRetry" in value + else value.get("status_codes_to_retry") ) - timeout = value.get("retryTimeoutInMilliseconds") - backoff = value.get("backoffCoefficient", 1.0) return cls( - first_retry_interval=timedelta(milliseconds=value["firstRetryIntervalInMilliseconds"]), - max_number_of_attempts=value["maxNumberOfAttempts"], + first_retry_interval=timedelta(milliseconds=first_interval), + max_number_of_attempts=max_attempts, backoff_coefficient=backoff if backoff is not None else 1.0, max_retry_interval=timedelta(milliseconds=max_interval) if max_interval is not None else None, retry_timeout=timedelta(milliseconds=timeout) if timeout is not None else None, - status_codes_to_retry=value.get("statusCodesToRetry")) + status_codes_to_retry=status_codes) class DurableHttpRequest: From f3deca3a3b029bbbac07eaf18b0cc6527429e31d Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 11 Nov 2026 04:18:13 +0530 Subject: [PATCH 3/6] test(http): verify HttpRetryOptions.from_json with missing optional fields Signed-off-by: Hardik Kaurani --- .../test_http_retry.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/azure-functions-durable/test_http_retry.py b/tests/azure-functions-durable/test_http_retry.py index 3273bef1..127013dd 100644 --- a/tests/azure-functions-durable/test_http_retry.py +++ b/tests/azure-functions-durable/test_http_retry.py @@ -49,6 +49,31 @@ def test_http_retry_defaults_and_round_trip(): assert df.HttpRetryOptions.from_json(json.loads(json.dumps(policy.to_json()))).to_json() == policy.to_json() +def test_http_retry_from_json_with_missing_optional_fields(): + # Only the required fields provided (as when serialized by minimal JSON or external SDKs) + wire = { + "firstRetryIntervalInMilliseconds": 1500, + "maxNumberOfAttempts": 4, + } + policy = df.HttpRetryOptions.from_json(wire) + assert policy.first_retry_interval == timedelta(milliseconds=1500) + assert policy.max_number_of_attempts == 4 + assert policy.backoff_coefficient == 1.0 + assert policy.max_retry_interval == timedelta(days=6) + assert policy.retry_timeout is None + assert policy.status_codes_to_retry is None + + # Wire payload explicitly setting maxRetryInterval to null (unbounded) + unbounded_wire = { + "firstRetryIntervalInMilliseconds": 2000, + "maxNumberOfAttempts": 2, + "maxRetryIntervalInMilliseconds": None, + } + unbounded_policy = df.HttpRetryOptions.from_json(unbounded_wire) + assert unbounded_policy.max_retry_interval is None + assert unbounded_policy.retry_timeout is None + + def test_request_snapshots_policy_and_status_list(): codes = [429, 503] policy = _options( From 6cd57bc58aee45cb909a0dab56a5b755b507411e Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 11 Nov 2026 04:18:45 +0530 Subject: [PATCH 4/6] test(http): verify HttpRetryOptions.from_json with snake_case keys Signed-off-by: Hardik Kaurani --- .../azure-functions-durable/test_http_retry.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/azure-functions-durable/test_http_retry.py b/tests/azure-functions-durable/test_http_retry.py index 127013dd..cecaa4b2 100644 --- a/tests/azure-functions-durable/test_http_retry.py +++ b/tests/azure-functions-durable/test_http_retry.py @@ -74,6 +74,24 @@ def test_http_retry_from_json_with_missing_optional_fields(): assert unbounded_policy.retry_timeout is None +def test_http_retry_from_json_with_snake_case_keys(): + snake_wire = { + "first_retry_interval_in_milliseconds": 2500, + "max_number_of_attempts": 5, + "backoff_coefficient": 1.5, + "max_retry_interval_in_milliseconds": 60000, + "retry_timeout_in_milliseconds": 120000, + "status_codes_to_retry": [429, 503], + } + policy = df.HttpRetryOptions.from_json(snake_wire) + assert policy.first_retry_interval == timedelta(milliseconds=2500) + assert policy.max_number_of_attempts == 5 + assert policy.backoff_coefficient == 1.5 + assert policy.max_retry_interval == timedelta(seconds=60) + assert policy.retry_timeout == timedelta(seconds=120) + assert policy.status_codes_to_retry == [429, 503] + + def test_request_snapshots_policy_and_status_list(): codes = [429, 503] policy = _options( From 0f056092345dfdf5ddf19bb72a5bd36795ded258 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 11 Nov 2026 04:19:13 +0530 Subject: [PATCH 5/6] docs(http): document defensive payload parsing in HttpRetryOptions.from_json Signed-off-by: Hardik Kaurani --- azure-functions-durable/CHANGELOG.md | 5 +++++ .../azure/durable_functions/http/models.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 0aba2344..aadbd839 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -20,6 +20,11 @@ minimum updates belong in the dedicated release PR. FIXED +- Reconstructing `HttpRetryOptions` from JSON via `from_json()` now defensively +handles missing optional fields (`maxRetryIntervalInMilliseconds`, +`retryTimeoutInMilliseconds`, `backoffCoefficient`, and `statusCodesToRetry`) and +accepts `snake_case` keys, preventing unexpected `KeyError` exceptions when parsing +minimal or cross-platform wire payloads. - With the corresponding core SDK update, activity and sub-orchestration `retry_timeout` decisions use replay-safe orchestration time rather than wall-clock time. See the [core upgrade guidance](../CHANGELOG.md#unreleased) diff --git a/azure-functions-durable/azure/durable_functions/http/models.py b/azure-functions-durable/azure/durable_functions/http/models.py index 3806ac0c..0bcfb49c 100644 --- a/azure-functions-durable/azure/durable_functions/http/models.py +++ b/azure-functions-durable/azure/durable_functions/http/models.py @@ -70,7 +70,12 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json(cls, value: dict[str, Any]) -> "HttpRetryOptions": - """Reconstruct a snapshotted policy from its wire payload.""" + """Reconstruct a snapshotted policy from its wire payload. + + Accepts both camelCase and snake_case key naming defensively, and handles + missing optional fields (max retry interval, retry timeout, backoff + coefficient, and status codes) using their class defaults. + """ first_interval = ( value["firstRetryIntervalInMilliseconds"] if "firstRetryIntervalInMilliseconds" in value From 2fe159724a624a909d493d5e98fba5040ca46ca9 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 11 Nov 2026 04:19:50 +0530 Subject: [PATCH 6/6] test(http): verify orchestrator execution with partial retry options payload Signed-off-by: Hardik Kaurani --- .../azure-functions-durable/test_http_retry.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/azure-functions-durable/test_http_retry.py b/tests/azure-functions-durable/test_http_retry.py index cecaa4b2..da0d90fb 100644 --- a/tests/azure-functions-durable/test_http_retry.py +++ b/tests/azure-functions-durable/test_http_retry.py @@ -436,3 +436,21 @@ def caller(context): completion = parent.only_action("completeOrchestration").completeOrchestration assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED assert "status code 503" in str(completion.failureDetails) + + +def test_poll_orchestrator_accepts_partial_retry_options_payload(): + payload = { + "method": "POST", + "uri": "https://example.com/start", + "retryOptions": { + "firstRetryIntervalInMilliseconds": 2000, + "maxNumberOfAttempts": 2, + }, + } + history = _History(builtin_http_poll_orchestrator, payload, parent="caller") + history.run_activity(status=503) + history.fire_timer(timedelta(seconds=2)) + history.run_activity() + assert history.result().status_code == 200 + assert history.result().content == "body" +