helix.fhir.client.sdk 4.2.3__py3-none-any.whl → 4.2.19__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.
Files changed (37) hide show
  1. helix_fhir_client_sdk/fhir_auth_mixin.py +17 -10
  2. helix_fhir_client_sdk/fhir_client.py +152 -79
  3. helix_fhir_client_sdk/fhir_delete_mixin.py +62 -48
  4. helix_fhir_client_sdk/fhir_merge_mixin.py +188 -166
  5. helix_fhir_client_sdk/fhir_merge_resources_mixin.py +200 -15
  6. helix_fhir_client_sdk/fhir_patch_mixin.py +97 -84
  7. helix_fhir_client_sdk/fhir_update_mixin.py +71 -57
  8. helix_fhir_client_sdk/graph/simulated_graph_processor_mixin.py +147 -49
  9. helix_fhir_client_sdk/open_telemetry/__init__.py +0 -0
  10. helix_fhir_client_sdk/open_telemetry/attribute_names.py +7 -0
  11. helix_fhir_client_sdk/open_telemetry/span_names.py +12 -0
  12. helix_fhir_client_sdk/queue/request_queue_mixin.py +17 -12
  13. helix_fhir_client_sdk/responses/fhir_client_protocol.py +10 -6
  14. helix_fhir_client_sdk/responses/fhir_get_response.py +3 -4
  15. helix_fhir_client_sdk/responses/fhir_response_processor.py +73 -54
  16. helix_fhir_client_sdk/responses/get/fhir_get_bundle_response.py +49 -28
  17. helix_fhir_client_sdk/responses/get/fhir_get_error_response.py +0 -1
  18. helix_fhir_client_sdk/responses/get/fhir_get_list_by_resource_type_response.py +1 -1
  19. helix_fhir_client_sdk/responses/get/fhir_get_list_response.py +1 -1
  20. helix_fhir_client_sdk/responses/get/fhir_get_response_factory.py +0 -1
  21. helix_fhir_client_sdk/responses/get/fhir_get_single_response.py +1 -1
  22. helix_fhir_client_sdk/responses/merge/fhir_merge_resource_response_entry.py +30 -0
  23. helix_fhir_client_sdk/responses/resource_separator.py +35 -40
  24. helix_fhir_client_sdk/utilities/cache/request_cache.py +32 -43
  25. helix_fhir_client_sdk/utilities/retryable_aiohttp_client.py +185 -154
  26. helix_fhir_client_sdk/utilities/retryable_aiohttp_response.py +2 -1
  27. helix_fhir_client_sdk/validators/async_fhir_validator.py +3 -0
  28. helix_fhir_client_sdk-4.2.19.dist-info/METADATA +200 -0
  29. {helix_fhir_client_sdk-4.2.3.dist-info → helix_fhir_client_sdk-4.2.19.dist-info}/RECORD +36 -29
  30. tests/async/test_benchmark_compress.py +448 -0
  31. tests/async/test_benchmark_merge.py +506 -0
  32. tests/async/test_retryable_client_session_management.py +159 -0
  33. tests/test_fhir_client_clone.py +155 -0
  34. helix_fhir_client_sdk-4.2.3.dist-info/METADATA +0 -115
  35. {helix_fhir_client_sdk-4.2.3.dist-info → helix_fhir_client_sdk-4.2.19.dist-info}/WHEEL +0 -0
  36. {helix_fhir_client_sdk-4.2.3.dist-info → helix_fhir_client_sdk-4.2.19.dist-info}/licenses/LICENSE +0 -0
  37. {helix_fhir_client_sdk-4.2.3.dist-info → helix_fhir_client_sdk-4.2.19.dist-info}/top_level.txt +0 -0
@@ -7,12 +7,15 @@ from typing import Any, cast
7
7
  import async_timeout
8
8
  from aiohttp import ClientError, ClientResponse, ClientResponseError, ClientSession
9
9
  from multidict import MultiMapping
10
+ from opentelemetry import trace
10
11
 
11
12
  from helix_fhir_client_sdk.function_types import (
12
13
  RefreshTokenFunction,
13
14
  RefreshTokenResult,
14
15
  TraceRequestFunction,
15
16
  )
17
+ from helix_fhir_client_sdk.open_telemetry.attribute_names import FhirClientSdkOpenTelemetryAttributeNames
18
+ from helix_fhir_client_sdk.open_telemetry.span_names import FhirClientSdkOpenTelemetrySpanNames
16
19
  from helix_fhir_client_sdk.utilities.retryable_aiohttp_response import (
17
20
  RetryableAioHttpResponse,
18
21
  )
@@ -20,6 +23,8 @@ from helix_fhir_client_sdk.utilities.retryable_aiohttp_url_result import (
20
23
  RetryableAioHttpUrlResult,
21
24
  )
22
25
 
26
+ TRACER = trace.get_tracer(__name__)
27
+
23
28
 
24
29
  class RetryableAioHttpClient:
25
30
  def __init__(
@@ -32,6 +37,7 @@ class RetryableAioHttpClient:
32
37
  refresh_token_func: RefreshTokenFunction | None,
33
38
  tracer_request_func: TraceRequestFunction | None,
34
39
  fn_get_session: Callable[[], ClientSession] | None = None,
40
+ caller_managed_session: bool = False,
35
41
  exclude_status_codes_from_retry: list[int] | None = None,
36
42
  use_data_streaming: bool | None,
37
43
  compress: bool | None = False,
@@ -40,13 +46,34 @@ class RetryableAioHttpClient:
40
46
  log_all_url_results: bool = False,
41
47
  access_token: str | None,
42
48
  access_token_expiry_date: datetime | None,
43
- persistent_session: ClientSession | None = None,
44
- use_persistent_session: bool = False,
45
- close_session_on_exit: bool = True,
46
49
  ) -> None:
47
50
  """
48
- RetryableClient provides a way to make HTTP calls with automatic retry and automatic refreshing of access tokens
51
+ RetryableClient provides a way to make HTTP calls with automatic retry and automatic refreshing of access tokens.
52
+
53
+ Session Lifecycle Management:
54
+ - If caller_managed_session is False (default): The SDK manages the session lifecycle.
55
+ The session will be automatically closed when exiting the context manager.
56
+ - If caller_managed_session is True: The caller is responsible for managing the session lifecycle.
57
+ The SDK will NOT close the session - the caller must close it themselves.
49
58
 
59
+ :param retries: Number of retry attempts for failed requests
60
+ :param timeout_in_seconds: Timeout for HTTP requests
61
+ :param backoff_factor: Factor for exponential backoff between retries
62
+ :param retry_status_codes: HTTP status codes that trigger a retry
63
+ :param refresh_token_func: Function to refresh authentication tokens
64
+ :param tracer_request_func: Function to trace/log requests
65
+ :param fn_get_session: Optional callable that returns a ClientSession. If None, a basic
66
+ ClientSession will be created internally.
67
+ :param caller_managed_session: If True, the caller is responsible for closing the session.
68
+ If False (default), the SDK will close the session on exit.
69
+ :param exclude_status_codes_from_retry: Status codes to exclude from retry logic
70
+ :param use_data_streaming: Whether to stream response data
71
+ :param compress: Whether to compress request data
72
+ :param send_data_as_chunked: Whether to use chunked transfer encoding
73
+ :param throw_exception_on_error: Whether to raise exceptions on HTTP errors
74
+ :param log_all_url_results: Whether to log all URL results
75
+ :param access_token: Access token for authentication
76
+ :param access_token_expiry_date: Expiry date of the access token
50
77
  """
51
78
  self.retries: int = retries
52
79
  self.timeout_in_seconds: float | None = timeout_in_seconds
@@ -56,6 +83,8 @@ class RetryableAioHttpClient:
56
83
  )
57
84
  self.refresh_token_func_async: RefreshTokenFunction | None = refresh_token_func
58
85
  self.trace_function_async: TraceRequestFunction | None = tracer_request_func
86
+ self._caller_managed_session: bool = caller_managed_session
87
+ # If no session factory provided, use a default one that creates a basic ClientSession
59
88
  self.fn_get_session: Callable[[], ClientSession] = (
60
89
  fn_get_session if fn_get_session is not None else lambda: ClientSession()
61
90
  )
@@ -68,15 +97,9 @@ class RetryableAioHttpClient:
68
97
  self.log_all_url_results: bool = log_all_url_results
69
98
  self.access_token: str | None = access_token
70
99
  self.access_token_expiry_date: datetime | None = access_token_expiry_date
71
- self.close_session_on_exit: bool = close_session_on_exit
72
- self.persistent_session: ClientSession | None = persistent_session
73
- self.use_persistent_session: bool = use_persistent_session
74
100
 
75
101
  async def __aenter__(self) -> "RetryableAioHttpClient":
76
- if self.use_persistent_session and self.persistent_session is not None:
77
- self.session = self.persistent_session
78
- else:
79
- self.session = self.fn_get_session()
102
+ self.session = self.fn_get_session()
80
103
  return self
81
104
 
82
105
  async def __aexit__(
@@ -85,7 +108,9 @@ class RetryableAioHttpClient:
85
108
  exc_val: BaseException | None,
86
109
  exc_tb: type[BaseException] | None | None,
87
110
  ) -> None:
88
- if self.session is not None and self.close_session_on_exit:
111
+ # Only close the session if SDK created it (fn_get_session was not provided)
112
+ # If the caller provided fn_get_session, they are responsible for closing the session
113
+ if not self._caller_managed_session and self.session is not None:
89
114
  await self.session.close()
90
115
 
91
116
  @staticmethod
@@ -119,7 +144,7 @@ class RetryableAioHttpClient:
119
144
  try:
120
145
  if headers:
121
146
  kwargs["headers"] = headers
122
- # if there is no data then remove from kwargs so as not to confuse aiohttp
147
+ # if there is no data, then remove from kwargs so as not to confuse aiohttp
123
148
  if "data" in kwargs and kwargs["data"] is None:
124
149
  del kwargs["data"]
125
150
  # compression and chunked can only be enabled if there is content sent
@@ -129,164 +154,101 @@ class RetryableAioHttpClient:
129
154
  if self.compress:
130
155
  kwargs["compress"] = self.compress
131
156
  assert self.session is not None
132
- async with async_timeout.timeout(self.timeout_in_seconds):
133
- start_time: float = time.time()
134
- response: ClientResponse = await self.session.request(
135
- method,
157
+ with TRACER.start_as_current_span(FhirClientSdkOpenTelemetrySpanNames.HTTP_GET) as span:
158
+ span.set_attribute(
159
+ FhirClientSdkOpenTelemetryAttributeNames.URL,
136
160
  url,
137
- **kwargs,
138
161
  )
139
- # Append the result to the list of results
140
- if self.log_all_url_results:
141
- results_by_url.append(
142
- RetryableAioHttpUrlResult(
162
+ async with async_timeout.timeout(self.timeout_in_seconds):
163
+ start_time: float = time.time()
164
+ response: ClientResponse = await self.session.request(
165
+ method,
166
+ url,
167
+ **kwargs,
168
+ )
169
+ # Append the result to the list of results
170
+ if self.log_all_url_results:
171
+ results_by_url.append(
172
+ RetryableAioHttpUrlResult(
173
+ ok=response.ok,
174
+ url=url,
175
+ status_code=response.status,
176
+ retry_count=retry_attempts,
177
+ start_time=start_time,
178
+ end_time=time.time(),
179
+ )
180
+ )
181
+ response_headers: dict[str, str] = {
182
+ k: ",".join(response.headers.getall(k)) for k in response.headers.keys()
183
+ }
184
+ response_headers_multi_mapping: MultiMapping[str] = cast(MultiMapping[str], response.headers)
185
+
186
+ if self.trace_function_async:
187
+ request_headers: dict[str, str] = {
188
+ k: ",".join(response.request_info.headers.getall(k))
189
+ for k in response.request_info.headers.keys()
190
+ }
191
+ await self.trace_function_async(
143
192
  ok=response.ok,
144
193
  url=url,
145
194
  status_code=response.status,
195
+ access_token=access_token,
196
+ expiry_date=expiry_date,
146
197
  retry_count=retry_attempts,
147
198
  start_time=start_time,
148
199
  end_time=time.time(),
200
+ request_headers=request_headers,
201
+ response_headers=response_headers,
149
202
  )
150
- )
151
- response_headers: dict[str, str] = {
152
- k: ",".join(response.headers.getall(k)) for k in response.headers.keys()
153
- }
154
- response_headers_multi_mapping: MultiMapping[str] = cast(MultiMapping[str], response.headers)
155
203
 
156
- if self.trace_function_async:
157
- request_headers: dict[str, str] = {
158
- k: ",".join(response.request_info.headers.getall(k))
159
- for k in response.request_info.headers.keys()
160
- }
161
- await self.trace_function_async(
162
- ok=response.ok,
163
- url=url,
164
- status_code=response.status,
165
- access_token=access_token,
166
- expiry_date=expiry_date,
167
- retry_count=retry_attempts,
168
- start_time=start_time,
169
- end_time=time.time(),
170
- request_headers=request_headers,
171
- response_headers=response_headers,
172
- )
173
-
174
- if response.ok:
175
- # If the response is successful, return the response
176
- return RetryableAioHttpResponse(
177
- ok=response.ok,
178
- status=response.status,
179
- response_headers=response_headers,
180
- response_text=(
181
- await self.get_safe_response_text_async(response=response)
182
- if not self.use_data_streaming
183
- else ""
184
- ),
185
- content=response.content,
186
- use_data_streaming=self.use_data_streaming,
187
- results_by_url=results_by_url,
188
- access_token=access_token,
189
- access_token_expiry_date=expiry_date,
190
- retry_count=retry_attempts,
191
- )
192
- elif (
193
- self.exclude_status_codes_from_retry and response.status in self.exclude_status_codes_from_retry
194
- ):
195
- return RetryableAioHttpResponse(
196
- ok=response.ok,
197
- status=response.status,
198
- response_headers=response_headers,
199
- response_text=await self.get_safe_response_text_async(response=response),
200
- content=response.content,
201
- use_data_streaming=self.use_data_streaming,
202
- results_by_url=results_by_url,
203
- access_token=access_token,
204
- access_token_expiry_date=expiry_date,
205
- retry_count=retry_attempts,
206
- )
207
- elif response.status == 400:
208
- return RetryableAioHttpResponse(
209
- ok=response.ok,
210
- status=response.status,
211
- response_headers=response_headers,
212
- response_text=await self.get_safe_response_text_async(response=response),
213
- content=response.content,
214
- use_data_streaming=self.use_data_streaming,
215
- results_by_url=results_by_url,
216
- access_token=access_token,
217
- access_token_expiry_date=expiry_date,
218
- retry_count=retry_attempts,
219
- )
220
- elif response.status in [403, 404]:
221
- return RetryableAioHttpResponse(
222
- ok=response.ok,
223
- status=response.status,
224
- response_headers=response_headers,
225
- response_text=await self.get_safe_response_text_async(response=response),
226
- content=response.content,
227
- use_data_streaming=self.use_data_streaming,
228
- results_by_url=results_by_url,
229
- access_token=access_token,
230
- access_token_expiry_date=expiry_date,
231
- retry_count=retry_attempts,
232
- )
233
- elif response.status == 429:
234
- await self._handle_429(response=response, full_url=url)
235
- elif response.status == 401 and self.refresh_token_func_async:
236
- # Call the token refresh function if status code is 401
237
- refresh_token_result: RefreshTokenResult = await self.refresh_token_func_async(
238
- current_token=access_token,
239
- expiry_date=expiry_date,
240
- url=url,
241
- status_code=response.status,
242
- retry_count=retry_attempts,
243
- )
244
- if refresh_token_result.abort_request or refresh_token_result.access_token is None:
204
+ if response.ok:
205
+ # If the response is successful, return the response
245
206
  return RetryableAioHttpResponse(
246
- ok=False,
247
- status=401,
248
- response_headers={},
249
- response_text="Unauthorized",
250
- content=None,
207
+ ok=response.ok,
208
+ status=response.status,
209
+ response_headers=response_headers,
210
+ response_text=(
211
+ await self.get_safe_response_text_async(response=response)
212
+ if not self.use_data_streaming
213
+ else ""
214
+ ),
215
+ content=response.content,
251
216
  use_data_streaming=self.use_data_streaming,
252
217
  results_by_url=results_by_url,
253
218
  access_token=access_token,
254
219
  access_token_expiry_date=expiry_date,
255
220
  retry_count=retry_attempts,
256
221
  )
257
- else: # we got a valid token
258
- access_token = refresh_token_result.access_token
259
- expiry_date = refresh_token_result.expiry_date
260
- if not headers:
261
- headers = {}
262
- headers["Authorization"] = f"Bearer {access_token}"
263
- if retry_attempts >= self.retries:
264
- raise ClientResponseError(
265
- status=response.status,
266
- message="Unauthorized",
267
- headers=response_headers_multi_mapping,
268
- history=response.history,
269
- request_info=response.request_info,
270
- )
271
- await asyncio.sleep(self.backoff_factor * (2 ** (retry_attempts - 1)))
272
- elif self.retry_status_codes and response.status in self.retry_status_codes:
273
- raise ClientResponseError(
274
- status=response.status,
275
- message="Retryable status code received",
276
- headers=response_headers_multi_mapping,
277
- history=response.history,
278
- request_info=response.request_info,
279
- )
280
- else:
281
- if self._throw_exception_on_error:
282
- raise ClientResponseError(
222
+ elif (
223
+ self.exclude_status_codes_from_retry
224
+ and response.status in self.exclude_status_codes_from_retry
225
+ ):
226
+ return RetryableAioHttpResponse(
227
+ ok=response.ok,
283
228
  status=response.status,
284
- message="Non-retryable status code received",
285
- headers=response_headers_multi_mapping,
286
- history=response.history,
287
- request_info=response.request_info,
229
+ response_headers=response_headers,
230
+ response_text=await self.get_safe_response_text_async(response=response),
231
+ content=response.content,
232
+ use_data_streaming=self.use_data_streaming,
233
+ results_by_url=results_by_url,
234
+ access_token=access_token,
235
+ access_token_expiry_date=expiry_date,
236
+ retry_count=retry_attempts,
288
237
  )
289
- else:
238
+ elif response.status == 400:
239
+ return RetryableAioHttpResponse(
240
+ ok=response.ok,
241
+ status=response.status,
242
+ response_headers=response_headers,
243
+ response_text=await self.get_safe_response_text_async(response=response),
244
+ content=response.content,
245
+ use_data_streaming=self.use_data_streaming,
246
+ results_by_url=results_by_url,
247
+ access_token=access_token,
248
+ access_token_expiry_date=expiry_date,
249
+ retry_count=retry_attempts,
250
+ )
251
+ elif response.status in [403, 404]:
290
252
  return RetryableAioHttpResponse(
291
253
  ok=response.ok,
292
254
  status=response.status,
@@ -299,6 +261,75 @@ class RetryableAioHttpClient:
299
261
  access_token_expiry_date=expiry_date,
300
262
  retry_count=retry_attempts,
301
263
  )
264
+ elif response.status == 429:
265
+ await self._handle_429(response=response, full_url=url)
266
+ elif response.status == 401 and self.refresh_token_func_async:
267
+ # Call the token refresh function if status code is 401
268
+ refresh_token_result: RefreshTokenResult = await self.refresh_token_func_async(
269
+ current_token=access_token,
270
+ expiry_date=expiry_date,
271
+ url=url,
272
+ status_code=response.status,
273
+ retry_count=retry_attempts,
274
+ )
275
+ if refresh_token_result.abort_request or refresh_token_result.access_token is None:
276
+ return RetryableAioHttpResponse(
277
+ ok=False,
278
+ status=401,
279
+ response_headers={},
280
+ response_text="Unauthorized",
281
+ content=None,
282
+ use_data_streaming=self.use_data_streaming,
283
+ results_by_url=results_by_url,
284
+ access_token=access_token,
285
+ access_token_expiry_date=expiry_date,
286
+ retry_count=retry_attempts,
287
+ )
288
+ else: # we got a valid token
289
+ access_token = refresh_token_result.access_token
290
+ expiry_date = refresh_token_result.expiry_date
291
+ if not headers:
292
+ headers = {}
293
+ headers["Authorization"] = f"Bearer {access_token}"
294
+ if retry_attempts >= self.retries:
295
+ raise ClientResponseError(
296
+ status=response.status,
297
+ message="Unauthorized",
298
+ headers=response_headers_multi_mapping,
299
+ history=response.history,
300
+ request_info=response.request_info,
301
+ )
302
+ await asyncio.sleep(self.backoff_factor * (2 ** (retry_attempts - 1)))
303
+ elif self.retry_status_codes and response.status in self.retry_status_codes:
304
+ raise ClientResponseError(
305
+ status=response.status,
306
+ message="Retryable status code received",
307
+ headers=response_headers_multi_mapping,
308
+ history=response.history,
309
+ request_info=response.request_info,
310
+ )
311
+ else:
312
+ if self._throw_exception_on_error:
313
+ raise ClientResponseError(
314
+ status=response.status,
315
+ message="Non-retryable status code received",
316
+ headers=response_headers_multi_mapping,
317
+ history=response.history,
318
+ request_info=response.request_info,
319
+ )
320
+ else:
321
+ return RetryableAioHttpResponse(
322
+ ok=response.ok,
323
+ status=response.status,
324
+ response_headers=response_headers,
325
+ response_text=await self.get_safe_response_text_async(response=response),
326
+ content=response.content,
327
+ use_data_streaming=self.use_data_streaming,
328
+ results_by_url=results_by_url,
329
+ access_token=access_token,
330
+ access_token_expiry_date=expiry_date,
331
+ retry_count=retry_attempts,
332
+ )
302
333
  except (TimeoutError, ClientError, ClientResponseError) as e:
303
334
  if retry_attempts >= self.retries:
304
335
  if self._throw_exception_on_error:
@@ -397,7 +428,7 @@ class RetryableAioHttpClient:
397
428
  if retry_after_text:
398
429
  # noinspection PyBroadException
399
430
  try:
400
- if retry_after_text.isnumeric(): # it is number of seconds
431
+ if retry_after_text.isnumeric(): # it is a number of seconds
401
432
  await asyncio.sleep(int(retry_after_text))
402
433
  else:
403
434
  wait_till: datetime = datetime.strptime(retry_after_text, "%a, %d %b %Y %H:%M:%S GMT")
@@ -411,7 +442,7 @@ class RetryableAioHttpClient:
411
442
  if time_diff > 0:
412
443
  await asyncio.sleep(time_diff)
413
444
  except Exception:
414
- # if there was some exception parsing the Retry-After header, sleep for 60 seconds
445
+ # if there was some exception, parsing the Retry-After header, sleep for 60 seconds
415
446
  await asyncio.sleep(60)
416
447
  else:
417
448
  await asyncio.sleep(60)
@@ -3,6 +3,7 @@ from datetime import datetime
3
3
  from typing import Any, cast
4
4
 
5
5
  from aiohttp import StreamReader
6
+ from multidict import CIMultiDict
6
7
 
7
8
  from helix_fhir_client_sdk.utilities.retryable_aiohttp_url_result import (
8
9
  RetryableAioHttpUrlResult,
@@ -53,7 +54,7 @@ class RetryableAioHttpResponse:
53
54
  self.status: int = status
54
55
  """ Status code of the response """
55
56
 
56
- self.response_headers: dict[str, str] = response_headers
57
+ self.response_headers: CIMultiDict[str] = CIMultiDict(response_headers)
57
58
  """ Headers of the response """
58
59
 
59
60
  self._response_text: str = response_text
@@ -23,6 +23,7 @@ class AsyncFhirValidator:
23
23
  resource_name: str,
24
24
  validation_server_url: str,
25
25
  access_token: str | None,
26
+ caller_managed_session: bool = False,
26
27
  ) -> None:
27
28
  """
28
29
  Calls the validation server url to validate the given resource
@@ -32,6 +33,7 @@ class AsyncFhirValidator:
32
33
  :param resource_name: name of resource
33
34
  :param validation_server_url: url to validation server
34
35
  :param access_token: access token to use
36
+ :param caller_managed_session: if True, the caller is responsible for closing the session
35
37
  """
36
38
  # check each resource against the validation server
37
39
  headers = {"Content-Type": "application/fhir+json"}
@@ -42,6 +44,7 @@ class AsyncFhirValidator:
42
44
  full_validation_uri /= "$validate"
43
45
  async with RetryableAioHttpClient(
44
46
  fn_get_session=fn_get_session,
47
+ caller_managed_session=caller_managed_session,
45
48
  use_data_streaming=False,
46
49
  access_token=access_token,
47
50
  access_token_expiry_date=None,