azure-functions-durable 1.4.0__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.
@@ -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
- client = client_constructor(starter)
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) -> str:
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, orchestration_function_name=orchestration_function_name)
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(request_url, json.dumps(event_data))
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(request_url, None)
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) -> 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(request_url, None)
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(request_url, None)
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(request_url, None)
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: Optional[str]
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) -> TaskBase:
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) -> TaskBase:
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(name, retry_options, input_, instance_id)
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) -> List[Union[int, Any]]:
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
- async with aiohttp.ClientSession() as session:
29
- headers = {}
30
- if trace_parent:
31
- headers["traceparent"] = trace_parent
32
- if trace_state:
33
- headers["tracestate"] = trace_state
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) -> List[Any]:
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
- async with aiohttp.ClientSession() as session:
57
- async with session.get(url) as response:
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) -> List[Union[int, Any]]:
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
- async with aiohttp.ClientSession() as session:
78
- async with session.delete(url) as response:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: azure-functions-durable
3
- Version: 1.4.0
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.12.14
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
- ## OpenAI Agents Integration for Reliability on Azure Functions (Preview)
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
 
@@ -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=5ajbhMPKvZKZhYLCxsEn8J4sZO6Pql9j0nOTCxDaMuY,12599
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=kQBqeKugvi2mi-7dDbCxlu67r20doEhDknlchYxcLBE,33018
12
- azure/durable_functions/models/DurableOrchestrationContext.py,sha256=fZNbUisN9vkublT2L7wYbj0CNfQNOyYcqRwZ9T-SgN8,32090
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=PBbS3aEKn1JCFhvjR9oz4-WmIW_9vh6-ILzImxVN7zw,16085
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=-q73TW--QhyIijfaCldF0QKQ3Ar9FbSAJCy1HBu1-mY,1541
33
- azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py,sha256=S2TD8ulOZlQhi-xB6uzBhbvYEo435sUkIx3Abs6F0oo,1799
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=AoCWjCapd_984J_4296iJ8cNJWEG8GIdhRttBPt0HnA,2551
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=7htzuMMfkRU9Hf-9Gr-rYHpJXJdpnAp0WheAFpMKHNo,31791
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
- azure_functions_durable-1.4.0.dist-info/LICENSE,sha256=-VS-Izmxdykuae1Xc4vHtVUx02rNQi6SSQlONvvuYeQ,1090
117
- azure_functions_durable-1.4.0.dist-info/METADATA,sha256=UvSARgbXXpFbz_zAnWhFr10Zjm85VPBTkTgZTG7g2jw,3778
118
- azure_functions_durable-1.4.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
119
- azure_functions_durable-1.4.0.dist-info/top_level.txt,sha256=h-L8XDVPJ9YzBbHlPvM7FVo1cqNGToNK9ix99ySGOUY,12
120
- azure_functions_durable-1.4.0.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.45.1)
2
+ Generator: bdist_wheel (0.46.3)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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, trace_state: 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