Skip to content

Commit c735508

Browse files
andystaplesCopilot
andauthored
Review Functions loose codec deprecation exceptions (#349)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 591f2db commit c735508

4 files changed

Lines changed: 439 additions & 4 deletions

File tree

‎azure-functions-durable/README.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,41 @@ 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+
## JSON payload typing and legacy compatibility
52+
53+
The provider uses the shared `azure-functions` JSON codec. Its default loose
54+
mode writes custom objects with `to_json` and reconstructs legacy envelopes,
55+
including nested objects, with `from_json`. Classes are resolved only from
56+
already-loaded modules. Where available, destination types are passed to the
57+
codec, such as a native orchestration input annotation, the v1-style
58+
`orchestration_trigger` `input_type` option, or an explicit `expected_type` on
59+
an API that accepts one. Loose mode can still reconstruct a different class
60+
from the one requested; it logs a warning rather than rejecting the mismatch.
61+
62+
Setting `AZURE_FUNCTIONS_DURABLE_STRICT_TYPING=1` opts into strict mode; the
63+
provider does not enable it automatically. To reconstruct a top-level
64+
custom-object envelope, strict mode requires a matching destination type with
65+
both `to_json` and `from_json` methods. Nested legacy envelopes in otherwise
66+
plain JSON remain dictionaries, and strict serialization does not support
67+
custom objects nested in plain containers.
68+
For a v1-style orchestration input, `get_input(expected_type=...)` alone is too
69+
late for the initial decode: declare `input_type` on `orchestration_trigger`
70+
when strict mode must reconstruct a top-level custom-object input.
71+
72+
The Functions worker does not pass activity parameter annotations to the
73+
activity trigger converter. Consequently, strict mode rejects a top-level
74+
custom-object activity input even when the activity has an annotation; this
75+
also applies to externalized payloads. Plain JSON activity inputs still decode
76+
without a destination type.
77+
78+
The shared codec emits one-time `DeprecationWarning` advisories in loose mode
79+
for decoding without a destination type and for reconstructing custom objects.
80+
These compatibility paths remain supported.
81+
82+
> [!WARNING]
83+
> Changing typing modes can change values returned during orchestration replay.
84+
> Validate existing payloads and drain affected orchestrations before switching.
85+
5186
## Durable HTTP failure retries
5287

5388
Opt in to durable retries for the **initial** `context.call_http()` request with

‎pyproject.toml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,11 @@ asyncio_default_fixture_loop_scope = "function"
105105
filterwarnings = [
106106
"error::DeprecationWarning",
107107
"error::PendingDeprecationWarning",
108-
# Legacy Functions payloads still require the upstream loose codec.
109-
# Report only these known warnings without failing; see issue #325.
110-
'default:azure\.functions df_loads was called without expected_type\.:DeprecationWarning:azure\.functions\._durable_functions$',
111-
'default:azure\.functions Durable JSON codec reconstructed a custom object via the loose-mode object_hook path\.:DeprecationWarning:azure\.functions\._durable_functions$',
108+
# Activity triggers can lack a destination type, and even typed loose-mode
109+
# decoding uses the object hook for legacy custom objects. Report only
110+
# these exact upstream advisories without failing; see issue #325.
111+
'default:^azure\.functions df_loads was called without expected_type\. Pass the destination type to enable validation and prepare for strict typing \(AZURE_FUNCTIONS_DURABLE_STRICT_TYPING=1\)\. This message will not be repeated\.\Z:DeprecationWarning:azure\.functions\._durable_functions\Z',
112+
'default:^azure\.functions Durable JSON codec reconstructed a custom object via the loose-mode object_hook path\. Set AZURE_FUNCTIONS_DURABLE_STRICT_TYPING=1 and supply expected_type at decode call sites to enable type-validated deserialization\. This message will not be repeated\.\Z:DeprecationWarning:azure\.functions\._durable_functions\Z',
112113
]
113114
markers = [
114115
"azurite: tests that require Azurite (local Azure Storage emulator)",
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Compatibility coverage for the shared Functions JSON codec."""
5+
6+
import base64
7+
from contextlib import nullcontext
8+
import inspect
9+
import json
10+
from unittest.mock import Mock
11+
import warnings
12+
13+
import pytest
14+
import azure.functions._durable_functions as shared_codec
15+
from azure.functions import meta
16+
17+
from azure.durable_functions import DFApp
18+
from azure.durable_functions.internal import payloads, serialization
19+
from azure.durable_functions.internal.compat.activity import wrap_activity_payloads
20+
from azure.durable_functions.internal.converters import ActivityTriggerConverter
21+
from azure.durable_functions.worker import DurableFunctionsWorker
22+
from durabletask.internal import helpers
23+
from durabletask.internal import orchestrator_service_pb2 as pb
24+
25+
26+
class LegacyPayload:
27+
def __init__(self, value):
28+
self.value = value
29+
30+
def to_json(self):
31+
return {"value": self.value}
32+
33+
@classmethod
34+
def from_json(cls, data):
35+
return cls(data["value"])
36+
37+
def __eq__(self, other):
38+
return isinstance(other, LegacyPayload) and self.value == other.value
39+
40+
41+
@pytest.fixture(autouse=True)
42+
def reset_codec_advisories(monkeypatch):
43+
monkeypatch.setattr(shared_codec, "_no_expected_type_notice_emitted", False)
44+
monkeypatch.setattr(shared_codec, "_loose_codec_notice_emitted", False)
45+
46+
47+
def encode_orchestration_request(value):
48+
encoded_input = serialization.FunctionsDataConverter().serialize(value)
49+
request = pb.OrchestratorRequest(instanceId="codec-compat")
50+
request.newEvents.extend([
51+
helpers.new_orchestrator_started_event(),
52+
helpers.new_execution_started_event(
53+
"codec_orchestrator", request.instanceId, encoded_input),
54+
])
55+
return base64.b64encode(request.SerializeToString()).decode("ascii")
56+
57+
58+
@pytest.mark.parametrize("strict", [False, True])
59+
@pytest.mark.parametrize("typed", [False, True])
60+
def test_custom_object_decode_preserves_typing_mode(monkeypatch, strict, typed):
61+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
62+
converter = serialization.FunctionsDataConverter()
63+
value = LegacyPayload(7)
64+
encoded = converter.serialize(value)
65+
target_type = LegacyPayload if typed else None
66+
67+
if strict and not typed:
68+
with pytest.raises(TypeError, match="strict mode requires expected_type"):
69+
converter.deserialize(encoded, target_type)
70+
elif strict:
71+
assert converter.deserialize(encoded, target_type) == value
72+
else:
73+
with pytest.warns(DeprecationWarning) as notices:
74+
assert converter.deserialize(encoded, target_type) == value
75+
messages = [str(notice.message) for notice in notices]
76+
assert len(messages) == (1 if typed else 2)
77+
assert any("loose-mode object_hook path" in message for message in messages)
78+
assert any("without expected_type" in message for message in messages) == (not typed)
79+
80+
81+
def test_loose_advisories_are_emitted_once(monkeypatch):
82+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "0")
83+
converter = serialization.FunctionsDataConverter()
84+
value = LegacyPayload(7)
85+
encoded = converter.serialize(value)
86+
with pytest.warns(DeprecationWarning) as notices:
87+
assert converter.deserialize(encoded) == value
88+
assert len(notices) == 2
89+
with warnings.catch_warnings():
90+
warnings.simplefilter("error", DeprecationWarning)
91+
assert converter.deserialize(encoded) == value
92+
93+
94+
@pytest.mark.parametrize("strict", [False, True])
95+
@pytest.mark.parametrize("target_type", [None, dict])
96+
def test_nested_legacy_objects_preserve_wire_shape(monkeypatch, strict, target_type):
97+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "0")
98+
converter = serialization.FunctionsDataConverter()
99+
encoded = converter.serialize({"items": [LegacyPayload(3)]})
100+
assert encoded is not None
101+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
102+
103+
if strict:
104+
assert converter.deserialize(encoded, target_type) == json.loads(encoded)
105+
else:
106+
with pytest.warns(DeprecationWarning) as notices:
107+
assert converter.deserialize(encoded, target_type) == {"items": [LegacyPayload(3)]}
108+
assert any("loose-mode object_hook path" in str(notice.message) for notice in notices)
109+
110+
111+
@pytest.mark.parametrize("strict", [False, True])
112+
def test_mismatched_destination_type_preserves_codec_policy(monkeypatch, caplog, strict):
113+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
114+
converter = serialization.FunctionsDataConverter()
115+
value = LegacyPayload(4)
116+
encoded = converter.serialize(value)
117+
118+
if strict:
119+
with pytest.raises(TypeError, match="payload declares class"):
120+
converter.deserialize(encoded, dict)
121+
else:
122+
with pytest.warns(DeprecationWarning, match="loose-mode object_hook path"):
123+
assert converter.deserialize(encoded, dict) == value
124+
assert "payload declares class" in caplog.text
125+
126+
127+
@pytest.mark.parametrize("target_type", [None, LegacyPayload])
128+
def test_loose_custom_object_requires_an_already_loaded_module(monkeypatch, target_type):
129+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "0")
130+
encoded = json.dumps({
131+
"__class__": "Missing",
132+
"__module__": "_durable_codec_missing_test_module",
133+
"__data__": {},
134+
})
135+
136+
notice = pytest.warns(DeprecationWarning, match="without expected_type") \
137+
if target_type is None else nullcontext()
138+
with notice:
139+
with pytest.raises(ValueError, match="not loaded in sys.modules"):
140+
serialization.FunctionsDataConverter().deserialize(encoded, target_type)
141+
142+
143+
@pytest.mark.parametrize("strict", [False, True])
144+
@pytest.mark.parametrize("native", [False, True])
145+
def test_orchestration_input_uses_declared_type(monkeypatch, strict, native):
146+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
147+
monkeypatch.setattr(payloads, "_payload_store", None)
148+
codec_loads = Mock(wraps=shared_codec.df_loads)
149+
monkeypatch.setattr(serialization, "df_loads", codec_loads)
150+
151+
def native_orchestrator(context, value: LegacyPayload):
152+
return value.value
153+
154+
def compat_orchestrator(context):
155+
return context.get_input().value
156+
157+
if native:
158+
orchestrator = native_orchestrator
159+
else:
160+
# The decorator also accepts v1's one-argument shape at runtime.
161+
builder = DFApp().orchestration_trigger(
162+
context_name="context", input_type=LegacyPayload)(
163+
compat_orchestrator) # pyright: ignore[reportArgumentType]
164+
orchestrator = getattr(builder.build().get_user_function(), "orchestrator_function")
165+
166+
wire_request = encode_orchestration_request(LegacyPayload(11))
167+
notice = pytest.warns(DeprecationWarning, match="loose-mode object_hook path") \
168+
if not strict else nullcontext()
169+
with notice:
170+
wire_response = DurableFunctionsWorker().execute_orchestration_request(
171+
orchestrator, wire_request)
172+
response = pb.OrchestratorResponse.FromString(base64.b64decode(wire_response))
173+
completion = response.actions[0].completeOrchestration
174+
175+
assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
176+
assert json.loads(completion.result.value) == 11
177+
assert codec_loads.call_args_list[0].kwargs["expected_type"] is LegacyPayload
178+
179+
180+
def test_v1_callsite_type_cannot_decode_initial_strict_input(monkeypatch):
181+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1")
182+
monkeypatch.setattr(payloads, "_payload_store", None)
183+
called = []
184+
185+
def compat_orchestrator(context):
186+
called.append(True)
187+
return context.get_input(expected_type=LegacyPayload).value
188+
189+
# The decorator also accepts v1's one-argument shape at runtime.
190+
builder = DFApp().orchestration_trigger(context_name="context")(
191+
compat_orchestrator) # pyright: ignore[reportArgumentType]
192+
orchestrator = getattr(builder.build().get_user_function(), "orchestrator_function")
193+
wire_response = DurableFunctionsWorker().execute_orchestration_request(
194+
orchestrator, encode_orchestration_request(LegacyPayload(11)))
195+
response = pb.OrchestratorResponse.FromString(base64.b64decode(wire_response))
196+
completion = response.actions[0].completeOrchestration
197+
198+
assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
199+
assert "strict mode requires expected_type" in completion.failureDetails.errorMessage
200+
assert called == []
201+
202+
203+
@pytest.mark.asyncio
204+
@pytest.mark.parametrize("strict", [False, True])
205+
@pytest.mark.parametrize("storage", ["disabled", "inline", "externalized"])
206+
@pytest.mark.parametrize("asynchronous", [False, True])
207+
@pytest.mark.parametrize("annotated", [False, True])
208+
async def test_activity_custom_input_remains_untyped(
209+
monkeypatch, payload_store_factory, strict, storage, asynchronous, annotated):
210+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "0")
211+
value = LegacyPayload(8)
212+
encoded = serialization.FunctionsDataConverter().serialize(value)
213+
assert encoded is not None
214+
store = payload_store_factory()
215+
monkeypatch.setattr(payloads, "_payload_store", None if storage == "disabled" else store)
216+
if storage == "externalized":
217+
encoded = json.dumps(store.upload(encoded.encode("utf-8")))
218+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
219+
received = []
220+
221+
def sync_activity(payload):
222+
received.append(payload)
223+
224+
async def async_activity(payload):
225+
received.append(payload)
226+
227+
activity = async_activity if asynchronous else sync_activity
228+
if annotated:
229+
activity.__annotations__["payload"] = LegacyPayload
230+
wrapper = wrap_activity_payloads(activity, "payload")
231+
if annotated:
232+
assert inspect.signature(wrapper).parameters["payload"].annotation is LegacyPayload
233+
234+
async def invoke():
235+
decoded = ActivityTriggerConverter.decode(
236+
meta.Datum(type="json", value=encoded), trigger_metadata=None)
237+
result = wrapper(decoded)
238+
return await result if asynchronous else result
239+
240+
if strict:
241+
with pytest.raises(ValueError) as error:
242+
await invoke()
243+
assert isinstance(error.value.__cause__, TypeError)
244+
assert "strict mode requires expected_type" in str(error.value.__cause__)
245+
assert received == []
246+
else:
247+
with pytest.warns(DeprecationWarning) as notices:
248+
assert await invoke() is None
249+
assert received == [value]
250+
messages = [str(notice.message) for notice in notices]
251+
assert any("without expected_type" in message for message in messages)
252+
assert any("loose-mode object_hook path" in message for message in messages)
253+
254+
255+
@pytest.mark.parametrize("strict", [False, True])
256+
@pytest.mark.parametrize("externalized", [False, True])
257+
def test_activity_annotation_does_not_validate_plain_json(
258+
monkeypatch, payload_store_factory, strict, externalized):
259+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
260+
store = payload_store_factory()
261+
monkeypatch.setattr(payloads, "_payload_store", store if externalized else None)
262+
encoded = json.dumps("not an integer")
263+
if externalized:
264+
encoded = json.dumps(store.upload(encoded.encode("utf-8")))
265+
received = []
266+
267+
def activity(payload: int):
268+
received.append(payload)
269+
270+
notice = pytest.warns(DeprecationWarning, match="without expected_type") \
271+
if not strict else nullcontext()
272+
with notice:
273+
decoded = ActivityTriggerConverter.decode(
274+
meta.Datum(type="json", value=encoded), trigger_metadata=None)
275+
assert wrap_activity_payloads(activity, "payload")(decoded) is None
276+
assert received == ["not an integer"]
277+
278+
279+
@pytest.mark.parametrize("strict", [False, True])
280+
@pytest.mark.parametrize("store_enabled", [False, True])
281+
def test_activity_non_json_string_fallback(
282+
monkeypatch, payload_store_factory, strict, store_enabled):
283+
monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "1" if strict else "0")
284+
monkeypatch.setattr(
285+
payloads, "_payload_store", payload_store_factory() if store_enabled else None)
286+
received = []
287+
288+
def activity(payload):
289+
received.append(payload)
290+
291+
notice = pytest.warns(DeprecationWarning, match="without expected_type") \
292+
if not strict else nullcontext()
293+
with notice:
294+
decoded = ActivityTriggerConverter.decode(
295+
meta.Datum(type="string", value="not-json"), trigger_metadata=None)
296+
assert wrap_activity_payloads(activity, "payload")(decoded) is None
297+
assert received == ["not-json"]

0 commit comments

Comments
 (0)