azure-functions-durable 1.4.0rc2__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure/durable_functions/decorators/durable_app.py +8 -1
- azure/durable_functions/models/DurableOrchestrationClient.py +49 -11
- azure/durable_functions/models/DurableOrchestrationContext.py +17 -6
- azure/durable_functions/models/TaskOrchestrationExecutor.py +1 -1
- azure/durable_functions/models/actions/CallSubOrchestratorAction.py +3 -1
- azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py +3 -1
- azure/durable_functions/models/utils/http_utils.py +123 -14
- {azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/METADATA +3 -3
- {azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/RECORD +14 -13
- {azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/WHEEL +1 -1
- tests/models/test_DurableOrchestrationClient.py +83 -1
- tests/utils/test_http_utils.py +287 -0
- {azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/LICENSE +0 -0
- {azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/top_level.txt +0 -0
|
@@ -195,7 +195,14 @@ class Blueprint(TriggerApi, BindingApi, SettingsApi):
|
|
|
195
195
|
# construct rich object from it,
|
|
196
196
|
# and assign parameter to that rich object
|
|
197
197
|
starter = kwargs[parameter_name]
|
|
198
|
-
|
|
198
|
+
|
|
199
|
+
# Try to extract the function invocation ID from the context for correlation
|
|
200
|
+
function_invocation_id = None
|
|
201
|
+
context = kwargs.get('context')
|
|
202
|
+
if context is not None and hasattr(context, 'invocation_id'):
|
|
203
|
+
function_invocation_id = context.invocation_id
|
|
204
|
+
|
|
205
|
+
client = client_constructor(starter, function_invocation_id)
|
|
199
206
|
kwargs[parameter_name] = client
|
|
200
207
|
|
|
201
208
|
# Invoke user code with rich DF Client binding
|
|
@@ -26,7 +26,16 @@ class DurableOrchestrationClient:
|
|
|
26
26
|
orchestration instances.
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
def __init__(self, context: str):
|
|
29
|
+
def __init__(self, context: str, function_invocation_id: Optional[str] = None):
|
|
30
|
+
"""Initialize a DurableOrchestrationClient.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
context : str
|
|
35
|
+
The JSON-encoded client binding context.
|
|
36
|
+
function_invocation_id : Optional[str]
|
|
37
|
+
The function invocation ID for correlation with host-side logs.
|
|
38
|
+
"""
|
|
30
39
|
self.task_hub_name: str
|
|
31
40
|
self._uniqueWebHookOrigins: List[str]
|
|
32
41
|
self._event_name_placeholder: str = "{eventName}"
|
|
@@ -39,6 +48,7 @@ class DurableOrchestrationClient:
|
|
|
39
48
|
self._show_history_query_key: str = "showHistory"
|
|
40
49
|
self._show_history_output_query_key: str = "showHistoryOutput"
|
|
41
50
|
self._show_input_query_key: str = "showInput"
|
|
51
|
+
self._function_invocation_id: Optional[str] = function_invocation_id
|
|
42
52
|
self._orchestration_bindings: DurableOrchestrationBindings = \
|
|
43
53
|
DurableOrchestrationBindings.from_json(context)
|
|
44
54
|
self._post_async_request = post_async_request
|
|
@@ -48,7 +58,8 @@ class DurableOrchestrationClient:
|
|
|
48
58
|
async def start_new(self,
|
|
49
59
|
orchestration_function_name: str,
|
|
50
60
|
instance_id: Optional[str] = None,
|
|
51
|
-
client_input: Optional[Any] = None
|
|
61
|
+
client_input: Optional[Any] = None,
|
|
62
|
+
version: Optional[str] = None) -> str:
|
|
52
63
|
"""Start a new instance of the specified orchestrator function.
|
|
53
64
|
|
|
54
65
|
If an orchestration instance with the specified ID already exists, the
|
|
@@ -63,6 +74,9 @@ class DurableOrchestrationClient:
|
|
|
63
74
|
the Durable Functions extension will generate a random GUID (recommended).
|
|
64
75
|
client_input : Optional[Any]
|
|
65
76
|
JSON-serializable input value for the orchestrator function.
|
|
77
|
+
version : Optional[str]
|
|
78
|
+
The version to assign to the orchestration instance. If not specified,
|
|
79
|
+
the defaultVersion from host.json will be used.
|
|
66
80
|
|
|
67
81
|
Returns
|
|
68
82
|
-------
|
|
@@ -70,7 +84,9 @@ class DurableOrchestrationClient:
|
|
|
70
84
|
The ID of the new orchestration instance if successful, None if not.
|
|
71
85
|
"""
|
|
72
86
|
request_url = self._get_start_new_url(
|
|
73
|
-
instance_id=instance_id,
|
|
87
|
+
instance_id=instance_id,
|
|
88
|
+
orchestration_function_name=orchestration_function_name,
|
|
89
|
+
version=version)
|
|
74
90
|
|
|
75
91
|
trace_parent, trace_state = DurableOrchestrationClient._get_current_activity_context()
|
|
76
92
|
|
|
@@ -78,7 +94,8 @@ class DurableOrchestrationClient:
|
|
|
78
94
|
request_url,
|
|
79
95
|
self._get_json_input(client_input),
|
|
80
96
|
trace_parent,
|
|
81
|
-
trace_state
|
|
97
|
+
trace_state,
|
|
98
|
+
self._function_invocation_id)
|
|
82
99
|
|
|
83
100
|
status_code: int = response[0]
|
|
84
101
|
if status_code <= 202 and response[1]:
|
|
@@ -250,7 +267,10 @@ class DurableOrchestrationClient:
|
|
|
250
267
|
request_url = self._get_raise_event_url(
|
|
251
268
|
instance_id, event_name, task_hub_name, connection_name)
|
|
252
269
|
|
|
253
|
-
response = await self._post_async_request(
|
|
270
|
+
response = await self._post_async_request(
|
|
271
|
+
request_url,
|
|
272
|
+
json.dumps(event_data),
|
|
273
|
+
function_invocation_id=self._function_invocation_id)
|
|
254
274
|
|
|
255
275
|
switch_statement = {
|
|
256
276
|
202: lambda: None,
|
|
@@ -439,7 +459,10 @@ class DurableOrchestrationClient:
|
|
|
439
459
|
"""
|
|
440
460
|
request_url = f"{self._orchestration_bindings.rpc_base_url}instances/{instance_id}/" \
|
|
441
461
|
f"terminate?reason={quote(reason)}"
|
|
442
|
-
response = await self._post_async_request(
|
|
462
|
+
response = await self._post_async_request(
|
|
463
|
+
request_url,
|
|
464
|
+
None,
|
|
465
|
+
function_invocation_id=self._function_invocation_id)
|
|
443
466
|
switch_statement = {
|
|
444
467
|
202: lambda: None, # instance in progress
|
|
445
468
|
410: lambda: None, # instance failed or terminated
|
|
@@ -558,7 +581,8 @@ class DurableOrchestrationClient:
|
|
|
558
581
|
request_url,
|
|
559
582
|
json.dumps(operation_input) if operation_input else None,
|
|
560
583
|
trace_parent,
|
|
561
|
-
trace_state
|
|
584
|
+
trace_state,
|
|
585
|
+
self._function_invocation_id)
|
|
562
586
|
|
|
563
587
|
switch_statement = {
|
|
564
588
|
202: lambda: None # signal accepted
|
|
@@ -639,10 +663,15 @@ class DurableOrchestrationClient:
|
|
|
639
663
|
raise Exception(result)
|
|
640
664
|
|
|
641
665
|
def _get_start_new_url(
|
|
642
|
-
self, instance_id: Optional[str], orchestration_function_name: str
|
|
666
|
+
self, instance_id: Optional[str], orchestration_function_name: str,
|
|
667
|
+
version: Optional[str] = None) -> str:
|
|
643
668
|
instance_path = f'/{instance_id}' if instance_id is not None else ''
|
|
644
669
|
request_url = f'{self._orchestration_bindings.rpc_base_url}orchestrators/' \
|
|
645
670
|
f'{orchestration_function_name}{instance_path}'
|
|
671
|
+
|
|
672
|
+
if version is not None:
|
|
673
|
+
request_url += f'?version={quote(version)}'
|
|
674
|
+
|
|
646
675
|
return request_url
|
|
647
676
|
|
|
648
677
|
def _get_raise_event_url(
|
|
@@ -703,7 +732,10 @@ class DurableOrchestrationClient:
|
|
|
703
732
|
raise Exception("The Python SDK only supports RPC endpoints."
|
|
704
733
|
+ "Please remove the `localRpcEnabled` setting from host.json")
|
|
705
734
|
|
|
706
|
-
response = await self._post_async_request(
|
|
735
|
+
response = await self._post_async_request(
|
|
736
|
+
request_url,
|
|
737
|
+
None,
|
|
738
|
+
function_invocation_id=self._function_invocation_id)
|
|
707
739
|
status: int = response[0]
|
|
708
740
|
ex_msg: str = ""
|
|
709
741
|
if status == 200 or status == 202:
|
|
@@ -742,7 +774,10 @@ class DurableOrchestrationClient:
|
|
|
742
774
|
"""
|
|
743
775
|
request_url = f"{self._orchestration_bindings.rpc_base_url}instances/{instance_id}/" \
|
|
744
776
|
f"suspend?reason={quote(reason)}"
|
|
745
|
-
response = await self._post_async_request(
|
|
777
|
+
response = await self._post_async_request(
|
|
778
|
+
request_url,
|
|
779
|
+
None,
|
|
780
|
+
function_invocation_id=self._function_invocation_id)
|
|
746
781
|
switch_statement = {
|
|
747
782
|
202: lambda: None, # instance is suspended
|
|
748
783
|
410: lambda: None, # instance completed
|
|
@@ -777,7 +812,10 @@ class DurableOrchestrationClient:
|
|
|
777
812
|
"""
|
|
778
813
|
request_url = f"{self._orchestration_bindings.rpc_base_url}instances/{instance_id}/" \
|
|
779
814
|
f"resume?reason={quote(reason)}"
|
|
780
|
-
response = await self._post_async_request(
|
|
815
|
+
response = await self._post_async_request(
|
|
816
|
+
request_url,
|
|
817
|
+
None,
|
|
818
|
+
function_invocation_id=self._function_invocation_id)
|
|
781
819
|
switch_statement = {
|
|
782
820
|
202: lambda: None, # instance is resumed
|
|
783
821
|
410: lambda: None, # instance completed
|
|
@@ -246,8 +246,10 @@ class DurableOrchestrationContext:
|
|
|
246
246
|
The HTTP request method.
|
|
247
247
|
uri: str
|
|
248
248
|
The HTTP request uri.
|
|
249
|
-
content:
|
|
250
|
-
The HTTP request content.
|
|
249
|
+
content: str or dict, optional
|
|
250
|
+
The HTTP request content. Can be a string or a JSON-serializable dictionary.
|
|
251
|
+
Note: Although the type hint says 'str', a dictionary is accepted
|
|
252
|
+
and will be serialized to JSON.
|
|
251
253
|
headers: Optional[Dict[str, str]]
|
|
252
254
|
The HTTP request headers.
|
|
253
255
|
token_source: TokenSource
|
|
@@ -285,7 +287,8 @@ class DurableOrchestrationContext:
|
|
|
285
287
|
|
|
286
288
|
def call_sub_orchestrator(self,
|
|
287
289
|
name: Union[str, Callable], input_: Optional[Any] = None,
|
|
288
|
-
instance_id: Optional[str] = None
|
|
290
|
+
instance_id: Optional[str] = None,
|
|
291
|
+
version: Optional[str] = None) -> TaskBase:
|
|
289
292
|
"""Schedule sub-orchestration function named `name` for execution.
|
|
290
293
|
|
|
291
294
|
Parameters
|
|
@@ -296,6 +299,9 @@ class DurableOrchestrationContext:
|
|
|
296
299
|
The JSON-serializable input to pass to the orchestrator function.
|
|
297
300
|
instance_id: Optional[str]
|
|
298
301
|
A unique ID to use for the sub-orchestration instance.
|
|
302
|
+
version: Optional[str]
|
|
303
|
+
The version to assign to the sub-orchestration instance. If not specified,
|
|
304
|
+
the defaultVersion from host.json will be used.
|
|
299
305
|
|
|
300
306
|
Returns
|
|
301
307
|
-------
|
|
@@ -313,14 +319,15 @@ class DurableOrchestrationContext:
|
|
|
313
319
|
if isinstance(name, FunctionBuilder):
|
|
314
320
|
name = self._get_function_name(name, OrchestrationTrigger)
|
|
315
321
|
|
|
316
|
-
action = CallSubOrchestratorAction(name, input_, instance_id)
|
|
322
|
+
action = CallSubOrchestratorAction(name, input_, instance_id, version)
|
|
317
323
|
task = self._generate_task(action)
|
|
318
324
|
return task
|
|
319
325
|
|
|
320
326
|
def call_sub_orchestrator_with_retry(self,
|
|
321
327
|
name: Union[str, Callable], retry_options: RetryOptions,
|
|
322
328
|
input_: Optional[Any] = None,
|
|
323
|
-
instance_id: Optional[str] = None
|
|
329
|
+
instance_id: Optional[str] = None,
|
|
330
|
+
version: Optional[str] = None) -> TaskBase:
|
|
324
331
|
"""Schedule sub-orchestration function named `name` for execution, with retry-options.
|
|
325
332
|
|
|
326
333
|
Parameters
|
|
@@ -333,6 +340,9 @@ class DurableOrchestrationContext:
|
|
|
333
340
|
The JSON-serializable input to pass to the activity function. Defaults to None.
|
|
334
341
|
instance_id: str
|
|
335
342
|
The instance ID of the sub-orchestrator to call.
|
|
343
|
+
version: Optional[str]
|
|
344
|
+
The version to assign to the sub-orchestration instance. If not specified,
|
|
345
|
+
the defaultVersion from host.json will be used.
|
|
336
346
|
|
|
337
347
|
Returns
|
|
338
348
|
-------
|
|
@@ -350,7 +360,8 @@ class DurableOrchestrationContext:
|
|
|
350
360
|
if isinstance(name, FunctionBuilder):
|
|
351
361
|
name = self._get_function_name(name, OrchestrationTrigger)
|
|
352
362
|
|
|
353
|
-
action = CallSubOrchestratorWithRetryAction(
|
|
363
|
+
action = CallSubOrchestratorWithRetryAction(
|
|
364
|
+
name, retry_options, input_, instance_id, version)
|
|
354
365
|
task = self._generate_task(action, retry_options)
|
|
355
366
|
return task
|
|
356
367
|
|
|
@@ -276,7 +276,7 @@ class TaskOrchestrationExecutor:
|
|
|
276
276
|
message contains in it the string representation of the orchestration's
|
|
277
277
|
state
|
|
278
278
|
"""
|
|
279
|
-
if(self.output is not None):
|
|
279
|
+
if (self.output is not None):
|
|
280
280
|
try:
|
|
281
281
|
# Attempt to serialize the output. If serialization fails, raise an
|
|
282
282
|
# error indicating that the orchestration output is not serializable,
|
|
@@ -11,10 +11,11 @@ class CallSubOrchestratorAction(Action):
|
|
|
11
11
|
"""Defines the structure of the Call SubOrchestrator object."""
|
|
12
12
|
|
|
13
13
|
def __init__(self, function_name: str, _input: Optional[Any] = None,
|
|
14
|
-
instance_id: Optional[str] = None):
|
|
14
|
+
instance_id: Optional[str] = None, version: Optional[str] = None):
|
|
15
15
|
self.function_name: str = function_name
|
|
16
16
|
self._input: str = dumps(_input, default=_serialize_custom_object)
|
|
17
17
|
self.instance_id: Optional[str] = instance_id
|
|
18
|
+
self.version: Optional[str] = version
|
|
18
19
|
|
|
19
20
|
if not self.function_name:
|
|
20
21
|
raise ValueError("function_name cannot be empty")
|
|
@@ -37,4 +38,5 @@ class CallSubOrchestratorAction(Action):
|
|
|
37
38
|
add_attrib(json_dict, self, 'function_name', 'functionName')
|
|
38
39
|
add_attrib(json_dict, self, '_input', 'input')
|
|
39
40
|
add_attrib(json_dict, self, 'instance_id', 'instanceId')
|
|
41
|
+
add_attrib(json_dict, self, 'version', 'version')
|
|
40
42
|
return json_dict
|
|
@@ -13,11 +13,12 @@ class CallSubOrchestratorWithRetryAction(Action):
|
|
|
13
13
|
|
|
14
14
|
def __init__(self, function_name: str, retry_options: RetryOptions,
|
|
15
15
|
_input: Optional[Any] = None,
|
|
16
|
-
instance_id: Optional[str] = None):
|
|
16
|
+
instance_id: Optional[str] = None, version: Optional[str] = None):
|
|
17
17
|
self.function_name: str = function_name
|
|
18
18
|
self._input: str = dumps(_input, default=_serialize_custom_object)
|
|
19
19
|
self.retry_options: RetryOptions = retry_options
|
|
20
20
|
self.instance_id: Optional[str] = instance_id
|
|
21
|
+
self.version: Optional[str] = version
|
|
21
22
|
|
|
22
23
|
if not self.function_name:
|
|
23
24
|
raise ValueError("function_name cannot be empty")
|
|
@@ -41,4 +42,5 @@ class CallSubOrchestratorWithRetryAction(Action):
|
|
|
41
42
|
add_attrib(json_dict, self, '_input', 'input')
|
|
42
43
|
add_json_attrib(json_dict, self, 'retry_options', 'retryOptions')
|
|
43
44
|
add_attrib(json_dict, self, 'instance_id', 'instanceId')
|
|
45
|
+
add_attrib(json_dict, self, 'version', 'version')
|
|
44
46
|
return json_dict
|
|
@@ -1,12 +1,87 @@
|
|
|
1
|
-
from typing import Any, List, Union
|
|
1
|
+
from typing import Any, List, Union, Optional
|
|
2
|
+
import asyncio
|
|
2
3
|
|
|
3
4
|
import aiohttp
|
|
4
5
|
|
|
5
6
|
|
|
7
|
+
# Global session and lock for thread-safe initialization
|
|
8
|
+
_client_session: Optional[aiohttp.ClientSession] = None
|
|
9
|
+
_session_lock: asyncio.Lock = asyncio.Lock()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _get_session() -> aiohttp.ClientSession:
|
|
13
|
+
"""Get or create the shared ClientSession.
|
|
14
|
+
|
|
15
|
+
Returns
|
|
16
|
+
-------
|
|
17
|
+
aiohttp.ClientSession
|
|
18
|
+
The shared client session with configured timeout and connection pooling.
|
|
19
|
+
"""
|
|
20
|
+
global _client_session
|
|
21
|
+
|
|
22
|
+
# Double-check locking pattern for async
|
|
23
|
+
if _client_session is None or _client_session.closed:
|
|
24
|
+
async with _session_lock:
|
|
25
|
+
# Check again after acquiring lock
|
|
26
|
+
if _client_session is None or _client_session.closed:
|
|
27
|
+
# Configure timeout optimized for localhost IPC
|
|
28
|
+
timeout = aiohttp.ClientTimeout(
|
|
29
|
+
total=240, # 4-minute total timeout for slow operations
|
|
30
|
+
sock_connect=10, # Fast connection over localhost
|
|
31
|
+
sock_read=None # Covered by total timeout
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Configure TCP connector optimized for localhost IPC
|
|
35
|
+
connector = aiohttp.TCPConnector(
|
|
36
|
+
limit=30, # Maximum connections for single host
|
|
37
|
+
limit_per_host=30, # Maximum connections per host
|
|
38
|
+
enable_cleanup_closed=True # Enable cleanup of closed connections
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_client_session = aiohttp.ClientSession(
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
connector=connector
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return _client_session
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _handle_request_error():
|
|
50
|
+
"""Handle connection errors by closing and resetting the session.
|
|
51
|
+
|
|
52
|
+
This handles cases where the remote host process recycles.
|
|
53
|
+
"""
|
|
54
|
+
global _client_session
|
|
55
|
+
async with _session_lock:
|
|
56
|
+
if _client_session is not None and not _client_session.closed:
|
|
57
|
+
try:
|
|
58
|
+
await _client_session.close()
|
|
59
|
+
finally:
|
|
60
|
+
_client_session = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _close_session() -> None:
|
|
64
|
+
"""Close the shared ClientSession if it exists.
|
|
65
|
+
|
|
66
|
+
Note: This function is currently only called by _handle_request_error().
|
|
67
|
+
There is no worker shutdown hook available, but process shutdown will
|
|
68
|
+
clean up all resources automatically.
|
|
69
|
+
"""
|
|
70
|
+
global _client_session
|
|
71
|
+
|
|
72
|
+
async with _session_lock:
|
|
73
|
+
if _client_session is not None and not _client_session.closed:
|
|
74
|
+
try:
|
|
75
|
+
await _client_session.close()
|
|
76
|
+
finally:
|
|
77
|
+
_client_session = None
|
|
78
|
+
|
|
79
|
+
|
|
6
80
|
async def post_async_request(url: str,
|
|
7
81
|
data: Any = None,
|
|
8
82
|
trace_parent: str = None,
|
|
9
|
-
trace_state: str = None
|
|
83
|
+
trace_state: str = None,
|
|
84
|
+
function_invocation_id: str = None) -> List[Union[int, Any]]:
|
|
10
85
|
"""Post request with the data provided to the url provided.
|
|
11
86
|
|
|
12
87
|
Parameters
|
|
@@ -19,18 +94,24 @@ async def post_async_request(url: str,
|
|
|
19
94
|
traceparent header to send with the request
|
|
20
95
|
trace_state: str
|
|
21
96
|
tracestate header to send with the request
|
|
97
|
+
function_invocation_id: str
|
|
98
|
+
function invocation ID header to send for correlation
|
|
22
99
|
|
|
23
100
|
Returns
|
|
24
101
|
-------
|
|
25
102
|
[int, Any]
|
|
26
103
|
Tuple with the Response status code and the data returned from the request
|
|
27
104
|
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
105
|
+
session = await _get_session()
|
|
106
|
+
headers = {}
|
|
107
|
+
if trace_parent:
|
|
108
|
+
headers["traceparent"] = trace_parent
|
|
109
|
+
if trace_state:
|
|
110
|
+
headers["tracestate"] = trace_state
|
|
111
|
+
if function_invocation_id:
|
|
112
|
+
headers["X-Azure-Functions-InvocationId"] = function_invocation_id
|
|
113
|
+
|
|
114
|
+
try:
|
|
34
115
|
async with session.post(url, json=data, headers=headers) as response:
|
|
35
116
|
# We disable aiohttp's input type validation
|
|
36
117
|
# as the server may respond with alternative
|
|
@@ -38,43 +119,71 @@ async def post_async_request(url: str,
|
|
|
38
119
|
# More here: https://docs.aiohttp.org/en/stable/client_advanced.html
|
|
39
120
|
data = await response.json(content_type=None)
|
|
40
121
|
return [response.status, data]
|
|
122
|
+
except (aiohttp.ClientError, asyncio.TimeoutError):
|
|
123
|
+
# On connection errors, close and recreate session for next request
|
|
124
|
+
await _handle_request_error()
|
|
125
|
+
raise
|
|
41
126
|
|
|
42
127
|
|
|
43
|
-
async def get_async_request(url: str
|
|
128
|
+
async def get_async_request(url: str,
|
|
129
|
+
function_invocation_id: str = None) -> List[Any]:
|
|
44
130
|
"""Get the data from the url provided.
|
|
45
131
|
|
|
46
132
|
Parameters
|
|
47
133
|
----------
|
|
48
134
|
url: str
|
|
49
135
|
url to get the data from
|
|
136
|
+
function_invocation_id: str
|
|
137
|
+
function invocation ID header to send for correlation
|
|
50
138
|
|
|
51
139
|
Returns
|
|
52
140
|
-------
|
|
53
141
|
[int, Any]
|
|
54
142
|
Tuple with the Response status code and the data returned from the request
|
|
55
143
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
144
|
+
session = await _get_session()
|
|
145
|
+
headers = {}
|
|
146
|
+
if function_invocation_id:
|
|
147
|
+
headers["X-Azure-Functions-InvocationId"] = function_invocation_id
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
async with session.get(url, headers=headers) as response:
|
|
58
151
|
data = await response.json(content_type=None)
|
|
59
152
|
if data is None:
|
|
60
153
|
data = ""
|
|
61
154
|
return [response.status, data]
|
|
155
|
+
except (aiohttp.ClientError, asyncio.TimeoutError):
|
|
156
|
+
# On connection errors, close and recreate session for next request
|
|
157
|
+
await _handle_request_error()
|
|
158
|
+
raise
|
|
62
159
|
|
|
63
160
|
|
|
64
|
-
async def delete_async_request(url: str
|
|
161
|
+
async def delete_async_request(url: str,
|
|
162
|
+
function_invocation_id: str = None) -> List[Union[int, Any]]:
|
|
65
163
|
"""Delete the data from the url provided.
|
|
66
164
|
|
|
67
165
|
Parameters
|
|
68
166
|
----------
|
|
69
167
|
url: str
|
|
70
168
|
url to delete the data from
|
|
169
|
+
function_invocation_id: str
|
|
170
|
+
function invocation ID header to send for correlation
|
|
71
171
|
|
|
72
172
|
Returns
|
|
73
173
|
-------
|
|
74
174
|
[int, Any]
|
|
75
175
|
Tuple with the Response status code and the data returned from the request
|
|
76
176
|
"""
|
|
77
|
-
|
|
78
|
-
|
|
177
|
+
session = await _get_session()
|
|
178
|
+
headers = {}
|
|
179
|
+
if function_invocation_id:
|
|
180
|
+
headers["X-Azure-Functions-InvocationId"] = function_invocation_id
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
async with session.delete(url, headers=headers) as response:
|
|
79
184
|
data = await response.json(content_type=None)
|
|
80
185
|
return [response.status, data]
|
|
186
|
+
except (aiohttp.ClientError, asyncio.TimeoutError):
|
|
187
|
+
# On connection errors, close and recreate session for next request
|
|
188
|
+
await _handle_request_error()
|
|
189
|
+
raise
|
{azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: azure-functions-durable
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Durable Functions For Python
|
|
5
5
|
Home-page: https://github.com/Azure/azure-functions-durable-python
|
|
6
6
|
Author: Azure Functions team at Microsoft Corp.
|
|
@@ -20,7 +20,7 @@ Requires-Python: >=3.9,<4
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Requires-Dist: azure-functions>=1.12.0
|
|
23
|
-
Requires-Dist: aiohttp>=3.
|
|
23
|
+
Requires-Dist: aiohttp>=3.13.3
|
|
24
24
|
Requires-Dist: requests==2.*
|
|
25
25
|
Requires-Dist: python-dateutil>=2.8.0
|
|
26
26
|
Requires-Dist: furl>=2.1.0
|
|
@@ -58,7 +58,7 @@ Follow these instructions to get started with Durable Functions in Python:
|
|
|
58
58
|
|
|
59
59
|
* Python Durable Functions requires [Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local) version 3.0.2630 or higher.
|
|
60
60
|
|
|
61
|
-
## Durable
|
|
61
|
+
## OpenAI Agent SDK Integration with Azure Durable Functions (Preview)
|
|
62
62
|
|
|
63
63
|
Build resilient, stateful AI agents backed by Durable Functions orchestration—see the full documentation at [docs/openai_agents/README.md](docs/openai_agents/README.md).
|
|
64
64
|
|
{azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/RECORD
RENAMED
|
@@ -3,13 +3,13 @@ azure/durable_functions/constants.py,sha256=JtknDhaVihMeo-ygY9QNofiO2KEqnQvopdfZ
|
|
|
3
3
|
azure/durable_functions/entity.py,sha256=mUUzb1BZiDrUJjvxOTlnVURnKPyDGPJ3mXXMN0DKT7M,4649
|
|
4
4
|
azure/durable_functions/orchestrator.py,sha256=SZni90Aweq0OZykHyMblfJpUndJ2woJmySarcsDiIK4,2554
|
|
5
5
|
azure/durable_functions/decorators/__init__.py,sha256=wEubgP2rUUISwidZWgKx6mmzEeGKsSnpGmetjIUi1nw,150
|
|
6
|
-
azure/durable_functions/decorators/durable_app.py,sha256=
|
|
6
|
+
azure/durable_functions/decorators/durable_app.py,sha256=swk5Bn9r6OiojnSDQQab83KPTVLmwtknCYA24bUL31E,12944
|
|
7
7
|
azure/durable_functions/decorators/metadata.py,sha256=p91rdCe6OSRYJaKAXnrfR0QCV3PoHK7aGy1m6WAnPIE,2828
|
|
8
8
|
azure/durable_functions/models/DurableEntityContext.py,sha256=cyZmjjZu18oV9S4A2NpnXfjd1JQxPxp9EMmAR424UK0,5830
|
|
9
9
|
azure/durable_functions/models/DurableHttpRequest.py,sha256=a5kgRdg4eA0sgyDcpmQWc0dbwP-o3BwWW2Ive0BYO_Q,2021
|
|
10
10
|
azure/durable_functions/models/DurableOrchestrationBindings.py,sha256=_hp61WjN3bQYCqYFQuvUaDdRu7C14fPg7lFbaA9TRe4,2408
|
|
11
|
-
azure/durable_functions/models/DurableOrchestrationClient.py,sha256=
|
|
12
|
-
azure/durable_functions/models/DurableOrchestrationContext.py,sha256=
|
|
11
|
+
azure/durable_functions/models/DurableOrchestrationClient.py,sha256=C07slwFZUxieHUqhbLL6Rdjb1OdN5aho0EPFsHWDDyg,34418
|
|
12
|
+
azure/durable_functions/models/DurableOrchestrationContext.py,sha256=QHh1i-UG5VoG9z0wMvlFMYL8Ye-nOPMm37p8Is6vCWs,32802
|
|
13
13
|
azure/durable_functions/models/DurableOrchestrationStatus.py,sha256=BXWz9L7np4Q9k6z4NsfLX97i2U2IFh94TVeRSV2BjM4,6049
|
|
14
14
|
azure/durable_functions/models/EntityStateResponse.py,sha256=f48W8gmlb-D5iJw3eDyUMYVwHpmIxP6k6a7o2TRHwII,674
|
|
15
15
|
azure/durable_functions/models/FunctionContext.py,sha256=4gHTmIo8DZN-bZLM-hyjoQFlv-AbsfLMT1_X4WxWxqY,274
|
|
@@ -20,7 +20,7 @@ azure/durable_functions/models/ReplaySchema.py,sha256=85YZD-QWgMQSfZsRQlfQfLPzK9
|
|
|
20
20
|
azure/durable_functions/models/RetryOptions.py,sha256=-rmv3mQmzQ_2utFy1d-ontqpcgP139B8MQroanfN54w,1988
|
|
21
21
|
azure/durable_functions/models/RpcManagementOptions.py,sha256=aEOWx_xUWl4Rwb2-7kpyml8rzTX9Vl4s71LkqYPLnHw,3482
|
|
22
22
|
azure/durable_functions/models/Task.py,sha256=e548_wfJkYY_UCpJkwqVVM65tyP6jchox_YNsf8SslM,19559
|
|
23
|
-
azure/durable_functions/models/TaskOrchestrationExecutor.py,sha256=
|
|
23
|
+
azure/durable_functions/models/TaskOrchestrationExecutor.py,sha256=BAwX-sm3aqcPYtSc0i0IQwF6cvQZblsELw_enpADTyY,16086
|
|
24
24
|
azure/durable_functions/models/TokenSource.py,sha256=9uLxiOV8lcDj--3tD0XxcQnigk9AozjdOoJyskctErU,1822
|
|
25
25
|
azure/durable_functions/models/__init__.py,sha256=L7ynxb_mBGCvV1iEAfpJU9_b-8ubKIEJKaZa2aoqjek,999
|
|
26
26
|
azure/durable_functions/models/actions/Action.py,sha256=0jp-SP_12YmZWWctOXmwl48Ozw3dMMq5-crUAkK8Qk0,598
|
|
@@ -29,8 +29,8 @@ azure/durable_functions/models/actions/CallActivityAction.py,sha256=G9O9JML-Z0_A
|
|
|
29
29
|
azure/durable_functions/models/actions/CallActivityWithRetryAction.py,sha256=0dG-NR1YQjiisNhbTsAxG7hCQZBwoJoD8b3B4iuQlmE,1686
|
|
30
30
|
azure/durable_functions/models/actions/CallEntityAction.py,sha256=rYXiWqY5cNBqQH7I4wZYjcU1FBTRrOHV-o5Kg8Ct7PE,1672
|
|
31
31
|
azure/durable_functions/models/actions/CallHttpAction.py,sha256=UaRTDzvO-W3MvSRj4zkxdSAWgU6qfm-0xjvPlrhPSGY,1162
|
|
32
|
-
azure/durable_functions/models/actions/CallSubOrchestratorAction.py,sha256
|
|
33
|
-
azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py,sha256=
|
|
32
|
+
azure/durable_functions/models/actions/CallSubOrchestratorAction.py,sha256=yYI7opZSSuf-h76TBOlF76wusJYf5UDbQPHZfxXUUnk,1678
|
|
33
|
+
azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py,sha256=7HWaUpSkQWcWON7k8GG1Njvy4l3ZilR1jOZ2ex9bUsA,1936
|
|
34
34
|
azure/durable_functions/models/actions/CompoundAction.py,sha256=qYySRtq5HuLLmOYMNFkrA0NuNlkT6RYuEeWQY7RIwvo,1117
|
|
35
35
|
azure/durable_functions/models/actions/ContinueAsNewAction.py,sha256=Jrj28QRqxEXHjcs4yGF3tusjtKGH00MBbo6Z5dz6J4k,1195
|
|
36
36
|
azure/durable_functions/models/actions/CreateTimerAction.py,sha256=pYVY4sH5iW7zecvueWljLBhVoLvXysmYdGE54URk90U,1575
|
|
@@ -51,7 +51,7 @@ azure/durable_functions/models/history/HistoryEventType.py,sha256=NdCQQrqvWFw5Gi
|
|
|
51
51
|
azure/durable_functions/models/history/__init__.py,sha256=otJhZJN9OeGtWrW3lKbk2C1Nyf6I2wJfwuXpCZ2oxYM,237
|
|
52
52
|
azure/durable_functions/models/utils/__init__.py,sha256=dQ6-HRUPsCtDIqGjRJ3TA6NXSYXzhw5yLA2OP-zkm-s,221
|
|
53
53
|
azure/durable_functions/models/utils/entity_utils.py,sha256=TqNTtRC8VuKFtqWLq9oEAloioV-FyinjgRYVKkCldHo,2881
|
|
54
|
-
azure/durable_functions/models/utils/http_utils.py,sha256=
|
|
54
|
+
azure/durable_functions/models/utils/http_utils.py,sha256=9-jLn-vLCCzxSkk_JyB4db0rk5Nqq7FHecavddPN1yA,6605
|
|
55
55
|
azure/durable_functions/models/utils/json_utils.py,sha256=zUn62pm3dQw054ZlK7F4uRP-UELjQC8EmZBU1WncHMg,3811
|
|
56
56
|
azure/durable_functions/openai_agents/__init__.py,sha256=pAUkXR5ctS0leHiR0IwBC1aHurzOn70wasL-LDRcRnQ,374
|
|
57
57
|
azure/durable_functions/openai_agents/context.py,sha256=tShhQmlMxvOGHvYu55e10xPp2mVHmeIUf37yUzzjE50,7551
|
|
@@ -70,7 +70,7 @@ tests/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
70
70
|
tests/models/test_DecoratorMetadata.py,sha256=0PeUDszF_gAJZMZR-K-Ro7c3I1D960amOLtbT88L_dk,3918
|
|
71
71
|
tests/models/test_Decorators.py,sha256=y2dhoSlP74J5uAVBDY2JfFkSA-AhyagVBZO5tGi6KaQ,2925
|
|
72
72
|
tests/models/test_DurableOrchestrationBindings.py,sha256=pjuoKlpEc6KAIL-Nq2taoqW0HYWXoupgUxcsPwc1Psg,2961
|
|
73
|
-
tests/models/test_DurableOrchestrationClient.py,sha256=
|
|
73
|
+
tests/models/test_DurableOrchestrationClient.py,sha256=JBi1l2ZPlZ9ksSpAvrlsKiLjyFwxWC-ol8Evp_dJmHU,35262
|
|
74
74
|
tests/models/test_DurableOrchestrationContext.py,sha256=ewNEH2g8gn60bVftXT6zmI6nkANXB1u7XsC1l9vXFxg,4328
|
|
75
75
|
tests/models/test_DurableOrchestrationStatus.py,sha256=fnUZxrHGy771OoaD5TInELhaG836aB8XqtMdNjnEFp8,2485
|
|
76
76
|
tests/models/test_OrchestrationState.py,sha256=L-k8ScrqoDIZEqIUORbxXA7yCuMbVAUPr-7VmyuQkUc,1272
|
|
@@ -113,8 +113,9 @@ tests/test_utils/json_utils.py,sha256=B0q3COMya7TGxbH-7sD_0ypWDSuaF4fpD4QV_oJPgG
|
|
|
113
113
|
tests/test_utils/testClasses.py,sha256=U_u5qKxC9U81SzjLo7ejjPjEn_cE5qjaqoq8edGD6l8,1521
|
|
114
114
|
tests/utils/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
|
|
115
115
|
tests/utils/test_entity_utils.py,sha256=kdk5_DV_-bFu_5q2mw9o1yjyzh8Lcxv1jo1Q7is_ukA,748
|
|
116
|
-
|
|
117
|
-
azure_functions_durable-1.
|
|
118
|
-
azure_functions_durable-1.
|
|
119
|
-
azure_functions_durable-1.
|
|
120
|
-
azure_functions_durable-1.
|
|
116
|
+
tests/utils/test_http_utils.py,sha256=-EJr4XX4611F-UVKTqLKMlKCDTMfZ7RWx7rXZj_xd-4,11171
|
|
117
|
+
azure_functions_durable-1.5.0.dist-info/LICENSE,sha256=-VS-Izmxdykuae1Xc4vHtVUx02rNQi6SSQlONvvuYeQ,1090
|
|
118
|
+
azure_functions_durable-1.5.0.dist-info/METADATA,sha256=cbh-wWD7zI8wf4MRxhzBDfKyNhtTIvN8BRhTBAkWIqY,3774
|
|
119
|
+
azure_functions_durable-1.5.0.dist-info/WHEEL,sha256=hPN0AlP2dZM_3ZJZWP4WooepkmU9wzjGgCLCeFjkHLA,92
|
|
120
|
+
azure_functions_durable-1.5.0.dist-info/top_level.txt,sha256=h-L8XDVPJ9YzBbHlPvM7FVo1cqNGToNK9ix99ySGOUY,12
|
|
121
|
+
azure_functions_durable-1.5.0.dist-info/RECORD,,
|
|
@@ -67,7 +67,8 @@ class MockRequest:
|
|
|
67
67
|
assert url == self._expected_url
|
|
68
68
|
return self._response
|
|
69
69
|
|
|
70
|
-
async def post(self, url: str, data: Any = None, trace_parent: str = None,
|
|
70
|
+
async def post(self, url: str, data: Any = None, trace_parent: str = None,
|
|
71
|
+
trace_state: str = None, function_invocation_id: str = None):
|
|
71
72
|
assert url == self._expected_url
|
|
72
73
|
return self._response
|
|
73
74
|
|
|
@@ -82,6 +83,17 @@ def test_get_start_new_url(binding_string):
|
|
|
82
83
|
assert expected_url == start_new_url
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
def test_get_start_new_url_with_version(binding_string):
|
|
87
|
+
client = DurableOrchestrationClient(binding_string)
|
|
88
|
+
instance_id = "2e2568e7-a906-43bd-8364-c81733c5891e"
|
|
89
|
+
function_name = "my_function"
|
|
90
|
+
version = "2.0"
|
|
91
|
+
start_new_url = client._get_start_new_url(instance_id, function_name, version)
|
|
92
|
+
expected_url = replace_stand_in_bits(
|
|
93
|
+
f"{RPC_BASE_URL}orchestrators/{function_name}/{instance_id}?version={version}")
|
|
94
|
+
assert expected_url == start_new_url
|
|
95
|
+
|
|
96
|
+
|
|
85
97
|
def test_get_input_returns_none_when_none_supplied():
|
|
86
98
|
result = DurableOrchestrationClient._get_json_input(None)
|
|
87
99
|
assert result is None
|
|
@@ -728,3 +740,73 @@ async def test_post_500_resume(binding_string):
|
|
|
728
740
|
|
|
729
741
|
with pytest.raises(Exception):
|
|
730
742
|
await client.resume(TEST_INSTANCE_ID, raw_reason)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# Tests for function_invocation_id parameter
|
|
746
|
+
def test_client_stores_function_invocation_id(binding_string):
|
|
747
|
+
"""Test that the client stores the function_invocation_id parameter."""
|
|
748
|
+
invocation_id = "test-invocation-123"
|
|
749
|
+
client = DurableOrchestrationClient(binding_string, function_invocation_id=invocation_id)
|
|
750
|
+
assert client._function_invocation_id == invocation_id
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def test_client_stores_none_when_no_invocation_id(binding_string):
|
|
754
|
+
"""Test that the client stores None when no invocation ID is provided."""
|
|
755
|
+
client = DurableOrchestrationClient(binding_string)
|
|
756
|
+
assert client._function_invocation_id is None
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class MockRequestWithInvocationId:
|
|
760
|
+
"""Mock request class that verifies function_invocation_id is passed."""
|
|
761
|
+
|
|
762
|
+
def __init__(self, expected_url: str, response: [int, any], expected_invocation_id: str = None):
|
|
763
|
+
self._expected_url = expected_url
|
|
764
|
+
self._response = response
|
|
765
|
+
self._expected_invocation_id = expected_invocation_id
|
|
766
|
+
self._received_invocation_id = None
|
|
767
|
+
|
|
768
|
+
@property
|
|
769
|
+
def received_invocation_id(self):
|
|
770
|
+
return self._received_invocation_id
|
|
771
|
+
|
|
772
|
+
async def post(self, url: str, data: Any = None, trace_parent: str = None,
|
|
773
|
+
trace_state: str = None, function_invocation_id: str = None):
|
|
774
|
+
assert url == self._expected_url
|
|
775
|
+
self._received_invocation_id = function_invocation_id
|
|
776
|
+
if self._expected_invocation_id is not None:
|
|
777
|
+
assert function_invocation_id == self._expected_invocation_id
|
|
778
|
+
return self._response
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@pytest.mark.asyncio
|
|
782
|
+
async def test_start_new_passes_invocation_id(binding_string):
|
|
783
|
+
"""Test that start_new passes the function_invocation_id to the HTTP request."""
|
|
784
|
+
invocation_id = "test-invocation-456"
|
|
785
|
+
function_name = "MyOrchestrator"
|
|
786
|
+
|
|
787
|
+
mock_request = MockRequestWithInvocationId(
|
|
788
|
+
expected_url=f"{RPC_BASE_URL}orchestrators/{function_name}",
|
|
789
|
+
response=[202, {"id": TEST_INSTANCE_ID}],
|
|
790
|
+
expected_invocation_id=invocation_id)
|
|
791
|
+
|
|
792
|
+
client = DurableOrchestrationClient(binding_string, function_invocation_id=invocation_id)
|
|
793
|
+
client._post_async_request = mock_request.post
|
|
794
|
+
|
|
795
|
+
await client.start_new(function_name)
|
|
796
|
+
assert mock_request.received_invocation_id == invocation_id
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
@pytest.mark.asyncio
|
|
800
|
+
async def test_start_new_passes_none_when_no_invocation_id(binding_string):
|
|
801
|
+
"""Test that start_new passes None when no invocation ID is provided."""
|
|
802
|
+
function_name = "MyOrchestrator"
|
|
803
|
+
|
|
804
|
+
mock_request = MockRequestWithInvocationId(
|
|
805
|
+
expected_url=f"{RPC_BASE_URL}orchestrators/{function_name}",
|
|
806
|
+
response=[202, {"id": TEST_INSTANCE_ID}])
|
|
807
|
+
|
|
808
|
+
client = DurableOrchestrationClient(binding_string)
|
|
809
|
+
client._post_async_request = mock_request.post
|
|
810
|
+
|
|
811
|
+
await client.start_new(function_name)
|
|
812
|
+
assert mock_request.received_invocation_id is None
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Tests for http_utils module to verify ClientSession reuse."""
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import AsyncMock, patch, Mock
|
|
4
|
+
from azure.durable_functions.models.utils import http_utils
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.mark.asyncio
|
|
8
|
+
async def test_session_is_reused_across_requests():
|
|
9
|
+
"""Test that the same session is reused for multiple requests."""
|
|
10
|
+
# Reset the session to start fresh
|
|
11
|
+
http_utils._client_session = None
|
|
12
|
+
|
|
13
|
+
# Make first request to create session
|
|
14
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
15
|
+
mock_session = Mock()
|
|
16
|
+
mock_response = AsyncMock()
|
|
17
|
+
mock_response.status = 200
|
|
18
|
+
mock_response.json = AsyncMock(return_value={"result": "success"})
|
|
19
|
+
|
|
20
|
+
# Create a proper async context manager
|
|
21
|
+
mock_post_context = AsyncMock()
|
|
22
|
+
mock_post_context.__aenter__.return_value = mock_response
|
|
23
|
+
mock_post_context.__aexit__.return_value = None
|
|
24
|
+
mock_session.post.return_value = mock_post_context
|
|
25
|
+
mock_session.closed = False
|
|
26
|
+
mock_session_class.return_value = mock_session
|
|
27
|
+
|
|
28
|
+
# First request
|
|
29
|
+
await http_utils.post_async_request("http://test.com",
|
|
30
|
+
{"data": "test1"})
|
|
31
|
+
|
|
32
|
+
# Verify session was created once
|
|
33
|
+
assert mock_session_class.call_count == 1
|
|
34
|
+
first_session = http_utils._client_session
|
|
35
|
+
|
|
36
|
+
# Second request - should reuse same session
|
|
37
|
+
await http_utils.post_async_request("http://test.com",
|
|
38
|
+
{"data": "test2"})
|
|
39
|
+
|
|
40
|
+
# Verify session was NOT created again
|
|
41
|
+
assert mock_session_class.call_count == 1
|
|
42
|
+
assert http_utils._client_session is first_session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_session_recreated_after_close():
|
|
47
|
+
"""Test that a new session is created if the previous one was closed."""
|
|
48
|
+
# Reset the session
|
|
49
|
+
http_utils._client_session = None
|
|
50
|
+
|
|
51
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
52
|
+
mock_session1 = Mock()
|
|
53
|
+
mock_session1.closed = False
|
|
54
|
+
mock_response = AsyncMock()
|
|
55
|
+
mock_response.status = 200
|
|
56
|
+
mock_response.json = AsyncMock(return_value={"result": "success"})
|
|
57
|
+
|
|
58
|
+
mock_post_context = AsyncMock()
|
|
59
|
+
mock_post_context.__aenter__.return_value = mock_response
|
|
60
|
+
mock_post_context.__aexit__.return_value = None
|
|
61
|
+
mock_session1.post.return_value = mock_post_context
|
|
62
|
+
|
|
63
|
+
mock_session2 = Mock()
|
|
64
|
+
mock_session2.closed = False
|
|
65
|
+
mock_session2.post.return_value = mock_post_context
|
|
66
|
+
|
|
67
|
+
mock_session_class.side_effect = [mock_session1, mock_session2]
|
|
68
|
+
|
|
69
|
+
# First request creates session
|
|
70
|
+
await http_utils.post_async_request("http://test.com",
|
|
71
|
+
{"data": "test1"})
|
|
72
|
+
assert mock_session_class.call_count == 1
|
|
73
|
+
|
|
74
|
+
# Simulate session being closed
|
|
75
|
+
mock_session1.closed = True
|
|
76
|
+
|
|
77
|
+
# Second request should create new session
|
|
78
|
+
await http_utils.post_async_request("http://test.com",
|
|
79
|
+
{"data": "test2"})
|
|
80
|
+
assert mock_session_class.call_count == 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_session_closed_on_connection_error():
|
|
85
|
+
"""Test that session is closed and reset on connection errors."""
|
|
86
|
+
# Reset the session
|
|
87
|
+
http_utils._client_session = None
|
|
88
|
+
|
|
89
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
90
|
+
mock_session = Mock()
|
|
91
|
+
mock_session.closed = False
|
|
92
|
+
mock_session.close = AsyncMock()
|
|
93
|
+
|
|
94
|
+
# First request succeeds
|
|
95
|
+
mock_response = AsyncMock()
|
|
96
|
+
mock_response.status = 200
|
|
97
|
+
mock_response.json = AsyncMock(return_value={"result": "success"})
|
|
98
|
+
|
|
99
|
+
mock_post_context_success = AsyncMock()
|
|
100
|
+
mock_post_context_success.__aenter__.return_value = mock_response
|
|
101
|
+
mock_post_context_success.__aexit__.return_value = None
|
|
102
|
+
|
|
103
|
+
mock_session.post.return_value = mock_post_context_success
|
|
104
|
+
mock_session_class.return_value = mock_session
|
|
105
|
+
|
|
106
|
+
await http_utils.post_async_request("http://test.com",
|
|
107
|
+
{"data": "test1"})
|
|
108
|
+
assert http_utils._client_session is not None
|
|
109
|
+
|
|
110
|
+
# Second request raises connection error
|
|
111
|
+
from aiohttp import ClientError
|
|
112
|
+
mock_post_context_error = AsyncMock()
|
|
113
|
+
mock_post_context_error.__aenter__.side_effect = \
|
|
114
|
+
ClientError("Connection failed")
|
|
115
|
+
mock_session.post.return_value = mock_post_context_error
|
|
116
|
+
|
|
117
|
+
with pytest.raises(ClientError):
|
|
118
|
+
await http_utils.post_async_request("http://test.com",
|
|
119
|
+
{"data": "test2"})
|
|
120
|
+
|
|
121
|
+
# Verify close was called
|
|
122
|
+
mock_session.close.assert_called_once()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_get_request_uses_shared_session():
|
|
127
|
+
"""Test that GET requests use the shared session."""
|
|
128
|
+
# Reset the session
|
|
129
|
+
http_utils._client_session = None
|
|
130
|
+
|
|
131
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
132
|
+
mock_session = Mock()
|
|
133
|
+
mock_session.closed = False
|
|
134
|
+
mock_response = AsyncMock()
|
|
135
|
+
mock_response.status = 200
|
|
136
|
+
mock_response.json = AsyncMock(return_value={"result": "data"})
|
|
137
|
+
|
|
138
|
+
mock_get_context = AsyncMock()
|
|
139
|
+
mock_get_context.__aenter__.return_value = mock_response
|
|
140
|
+
mock_get_context.__aexit__.return_value = None
|
|
141
|
+
mock_session.get.return_value = mock_get_context
|
|
142
|
+
mock_session_class.return_value = mock_session
|
|
143
|
+
|
|
144
|
+
# Make GET request
|
|
145
|
+
await http_utils.get_async_request("http://test.com")
|
|
146
|
+
|
|
147
|
+
# Make another GET request
|
|
148
|
+
await http_utils.get_async_request("http://test.com")
|
|
149
|
+
|
|
150
|
+
# Verify session was created only once
|
|
151
|
+
assert mock_session_class.call_count == 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_delete_request_uses_shared_session():
|
|
156
|
+
"""Test that DELETE requests use the shared session."""
|
|
157
|
+
# Reset the session
|
|
158
|
+
http_utils._client_session = None
|
|
159
|
+
|
|
160
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
161
|
+
mock_session = Mock()
|
|
162
|
+
mock_session.closed = False
|
|
163
|
+
mock_response = AsyncMock()
|
|
164
|
+
mock_response.status = 200
|
|
165
|
+
mock_response.json = AsyncMock(return_value={"result": "deleted"})
|
|
166
|
+
|
|
167
|
+
mock_delete_context = AsyncMock()
|
|
168
|
+
mock_delete_context.__aenter__.return_value = mock_response
|
|
169
|
+
mock_delete_context.__aexit__.return_value = None
|
|
170
|
+
mock_session.delete.return_value = mock_delete_context
|
|
171
|
+
mock_session_class.return_value = mock_session
|
|
172
|
+
|
|
173
|
+
# Make DELETE request
|
|
174
|
+
await http_utils.delete_async_request("http://test.com")
|
|
175
|
+
|
|
176
|
+
# Make another DELETE request
|
|
177
|
+
await http_utils.delete_async_request("http://test.com")
|
|
178
|
+
|
|
179
|
+
# Verify session was created only once
|
|
180
|
+
assert mock_session_class.call_count == 1
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.mark.asyncio
|
|
184
|
+
async def test_session_configured_with_timeouts():
|
|
185
|
+
"""Test that session is configured with appropriate timeouts."""
|
|
186
|
+
# Reset the session
|
|
187
|
+
http_utils._client_session = None
|
|
188
|
+
|
|
189
|
+
with patch('aiohttp.ClientSession') as mock_session_class, \
|
|
190
|
+
patch('aiohttp.ClientTimeout') as mock_timeout_class, \
|
|
191
|
+
patch('aiohttp.TCPConnector') as mock_connector_class:
|
|
192
|
+
|
|
193
|
+
mock_session = Mock()
|
|
194
|
+
mock_session.closed = False
|
|
195
|
+
mock_response = AsyncMock()
|
|
196
|
+
mock_response.status = 200
|
|
197
|
+
mock_response.json = AsyncMock(return_value={"result": "success"})
|
|
198
|
+
|
|
199
|
+
mock_post_context = AsyncMock()
|
|
200
|
+
mock_post_context.__aenter__.return_value = mock_response
|
|
201
|
+
mock_post_context.__aexit__.return_value = None
|
|
202
|
+
mock_session.post.return_value = mock_post_context
|
|
203
|
+
mock_session_class.return_value = mock_session
|
|
204
|
+
|
|
205
|
+
await http_utils.post_async_request("http://test.com",
|
|
206
|
+
{"data": "test"})
|
|
207
|
+
|
|
208
|
+
# Verify timeout was configured for localhost IPC
|
|
209
|
+
mock_timeout_class.assert_called_once()
|
|
210
|
+
timeout_call = mock_timeout_class.call_args
|
|
211
|
+
assert timeout_call.kwargs['total'] == 240
|
|
212
|
+
assert timeout_call.kwargs['sock_connect'] == 10
|
|
213
|
+
assert timeout_call.kwargs['sock_read'] is None
|
|
214
|
+
|
|
215
|
+
# Verify connector was configured for localhost IPC
|
|
216
|
+
mock_connector_class.assert_called_once()
|
|
217
|
+
connector_call = mock_connector_class.call_args
|
|
218
|
+
assert connector_call.kwargs['limit'] == 30
|
|
219
|
+
assert connector_call.kwargs['limit_per_host'] == 30
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_close_session():
|
|
224
|
+
"""Test the _close_session function."""
|
|
225
|
+
# Reset and create a session
|
|
226
|
+
http_utils._client_session = None
|
|
227
|
+
|
|
228
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
229
|
+
mock_session = Mock()
|
|
230
|
+
mock_session.closed = False
|
|
231
|
+
mock_session.close = AsyncMock()
|
|
232
|
+
mock_response = AsyncMock()
|
|
233
|
+
mock_response.status = 200
|
|
234
|
+
mock_response.json = AsyncMock(return_value={"result": "success"})
|
|
235
|
+
|
|
236
|
+
mock_post_context = AsyncMock()
|
|
237
|
+
mock_post_context.__aenter__.return_value = mock_response
|
|
238
|
+
mock_post_context.__aexit__.return_value = None
|
|
239
|
+
mock_session.post.return_value = mock_post_context
|
|
240
|
+
mock_session_class.return_value = mock_session
|
|
241
|
+
|
|
242
|
+
# Create session
|
|
243
|
+
await http_utils.post_async_request("http://test.com",
|
|
244
|
+
{"data": "test"})
|
|
245
|
+
assert http_utils._client_session is not None
|
|
246
|
+
|
|
247
|
+
# Close session
|
|
248
|
+
await http_utils._close_session()
|
|
249
|
+
|
|
250
|
+
# Verify close was called and session is None
|
|
251
|
+
mock_session.close.assert_called_once()
|
|
252
|
+
assert http_utils._client_session is None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_trace_headers_are_passed():
|
|
257
|
+
"""Test that trace headers are properly passed in requests."""
|
|
258
|
+
# Reset the session
|
|
259
|
+
http_utils._client_session = None
|
|
260
|
+
|
|
261
|
+
with patch('aiohttp.ClientSession') as mock_session_class:
|
|
262
|
+
mock_session = Mock()
|
|
263
|
+
mock_session.closed = False
|
|
264
|
+
mock_response = AsyncMock()
|
|
265
|
+
mock_response.status = 200
|
|
266
|
+
mock_response.json = AsyncMock(return_value={"result": "success"})
|
|
267
|
+
|
|
268
|
+
mock_post_context = AsyncMock()
|
|
269
|
+
mock_post_context.__aenter__.return_value = mock_response
|
|
270
|
+
mock_post_context.__aexit__.return_value = None
|
|
271
|
+
mock_session.post.return_value = mock_post_context
|
|
272
|
+
mock_session_class.return_value = mock_session
|
|
273
|
+
|
|
274
|
+
trace_parent = "00-trace-id-parent"
|
|
275
|
+
trace_state = "state=value"
|
|
276
|
+
|
|
277
|
+
await http_utils.post_async_request(
|
|
278
|
+
"http://test.com",
|
|
279
|
+
{"data": "test"},
|
|
280
|
+
trace_parent=trace_parent,
|
|
281
|
+
trace_state=trace_state
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Verify headers were passed
|
|
285
|
+
call_args = mock_session.post.call_args
|
|
286
|
+
assert call_args.kwargs['headers']['traceparent'] == trace_parent
|
|
287
|
+
assert call_args.kwargs['headers']['tracestate'] == trace_state
|
{azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/LICENSE
RENAMED
|
File without changes
|
{azure_functions_durable-1.4.0rc2.dist-info → azure_functions_durable-1.5.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|