helix.fhir.client.sdk 4.2.14__py3-none-any.whl → 4.2.16__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.
@@ -118,7 +118,6 @@ class FhirMergeResourcesMixin(FhirClientProtocol):
118
118
  log_all_url_results=self._log_all_response_urls,
119
119
  access_token=self._access_token,
120
120
  access_token_expiry_date=self._access_token_expiry_date,
121
- persistent_session=self._persistent_session,
122
121
  ) as client:
123
122
  http_post_start = time.time()
124
123
  response: RetryableAioHttpResponse = await client.post(
@@ -312,7 +311,6 @@ class FhirMergeResourcesMixin(FhirClientProtocol):
312
311
  log_all_url_results=self._log_all_response_urls,
313
312
  access_token=self._access_token,
314
313
  access_token_expiry_date=self._access_token_expiry_date,
315
- persistent_session=self._persistent_session,
316
314
  ) as client:
317
315
  # should we check if it exists and do a POST then?
318
316
  response: RetryableAioHttpResponse = await client.post(
@@ -529,7 +527,6 @@ class FhirMergeResourcesMixin(FhirClientProtocol):
529
527
  log_all_url_results=self._log_all_response_urls,
530
528
  access_token=self._access_token,
531
529
  access_token_expiry_date=self._access_token_expiry_date,
532
- persistent_session=self._persistent_session,
533
530
  ) as client:
534
531
  # should we check if it exists and do a POST then?
535
532
  response: RetryableAioHttpResponse = await client.post(
@@ -2,8 +2,12 @@ import json
2
2
  import time
3
3
 
4
4
  from furl import furl
5
+ from opentelemetry import trace
6
+ from opentelemetry.trace import Status, StatusCode
5
7
 
6
8
  from helix_fhir_client_sdk.exceptions.fhir_sender_exception import FhirSenderException
9
+ from helix_fhir_client_sdk.open_telemetry.attribute_names import FhirClientSdkOpenTelemetryAttributeNames
10
+ from helix_fhir_client_sdk.open_telemetry.span_names import FhirClientSdkOpenTelemetrySpanNames
7
11
  from helix_fhir_client_sdk.responses.fhir_client_protocol import FhirClientProtocol
8
12
  from helix_fhir_client_sdk.responses.fhir_update_response import FhirUpdateResponse
9
13
  from helix_fhir_client_sdk.structures.get_access_token_result import (
@@ -18,6 +22,8 @@ from helix_fhir_client_sdk.utilities.retryable_aiohttp_response import (
18
22
  RetryableAioHttpResponse,
19
23
  )
20
24
 
25
+ TRACER = trace.get_tracer(__name__)
26
+
21
27
 
22
28
  class FhirPatchMixin(FhirClientProtocol):
23
29
  async def send_patch_request_async(self, data: str) -> FhirUpdateResponse:
@@ -33,94 +39,102 @@ class FhirPatchMixin(FhirClientProtocol):
33
39
  raise ValueError("update should have only one id")
34
40
  if not self._resource:
35
41
  raise ValueError("update requires a FHIR resource type")
36
- self._internal_logger.debug(
37
- f"Calling patch method on {self._url} with client_id={self._client_id} and scopes={self._auth_scopes}"
38
- )
39
- full_uri: furl = furl(self._url)
40
- full_uri /= self._resource
41
- full_uri /= self._id
42
- request_id: str | None = None
43
42
 
44
- start_time: float = time.time()
43
+ with TRACER.start_as_current_span(FhirClientSdkOpenTelemetrySpanNames.PATCH) as span:
44
+ span.set_attribute(FhirClientSdkOpenTelemetryAttributeNames.URL, self._url or "")
45
+ span.set_attribute(FhirClientSdkOpenTelemetryAttributeNames.RESOURCE, self._resource or "")
46
+ try:
47
+ self._internal_logger.debug(
48
+ f"Calling patch method on {self._url} with client_id={self._client_id} and scopes={self._auth_scopes}"
49
+ )
50
+ full_uri: furl = furl(self._url)
51
+ full_uri /= self._resource
52
+ full_uri /= self._id
53
+ request_id: str | None = None
45
54
 
46
- # Set up headers
47
- headers = {"Content-Type": "application/json-patch+json"}
48
- headers.update(self._additional_request_headers)
49
- self._internal_logger.debug(f"Request headers: {headers}")
50
- access_token_result: GetAccessTokenResult = await self.get_access_token_async()
51
- access_token: str | None = access_token_result.access_token
52
- # set access token in request if present
53
- if access_token:
54
- headers["Authorization"] = f"Bearer {access_token}"
55
+ start_time: float = time.time()
55
56
 
56
- response_text: str | None = None
57
- response_status: int | None = None
57
+ # Set up headers
58
+ headers = {"Content-Type": "application/json-patch+json"}
59
+ headers.update(self._additional_request_headers)
60
+ self._internal_logger.debug(f"Request headers: {headers}")
61
+ access_token_result: GetAccessTokenResult = await self.get_access_token_async()
62
+ access_token: str | None = access_token_result.access_token
63
+ # set access token in request if present
64
+ if access_token:
65
+ headers["Authorization"] = f"Bearer {access_token}"
58
66
 
59
- try:
60
- deserialized_data = json.loads(data)
61
- # actually make the request
62
- async with RetryableAioHttpClient(
63
- fn_get_session=lambda: self.create_http_session(),
64
- refresh_token_func=self._refresh_token_function,
65
- tracer_request_func=self._trace_request_function,
66
- retries=self._retry_count,
67
- exclude_status_codes_from_retry=self._exclude_status_codes_from_retry,
68
- use_data_streaming=self._use_data_streaming,
69
- send_data_as_chunked=self._send_data_as_chunked,
70
- compress=self._compress,
71
- throw_exception_on_error=self._throw_exception_on_error,
72
- log_all_url_results=self._log_all_response_urls,
73
- access_token=self._access_token,
74
- access_token_expiry_date=self._access_token_expiry_date,
75
- persistent_session=self._persistent_session,
76
- ) as client:
77
- response: RetryableAioHttpResponse = await client.patch(
78
- url=full_uri.url, json=deserialized_data, headers=headers
79
- )
80
- response_status = response.status
81
- response_text = await response.get_text_async()
82
- request_id = response.response_headers.get("X-Request-ID", None)
83
- self._internal_logger.debug(f"X-Request-ID={request_id}")
67
+ response_text: str | None = None
68
+ response_status: int | None = None
84
69
 
85
- if response_status == 200:
86
- if self._logger:
87
- self._logger.info(f"Successfully updated: {full_uri}")
88
- elif response_status == 404:
89
- if self._logger:
90
- self._logger.info(f"Request resource was not found: {full_uri}")
70
+ try:
71
+ deserialized_data = json.loads(data)
72
+ # actually make the request
73
+ async with RetryableAioHttpClient(
74
+ fn_get_session=lambda: self.create_http_session(),
75
+ refresh_token_func=self._refresh_token_function,
76
+ tracer_request_func=self._trace_request_function,
77
+ retries=self._retry_count,
78
+ exclude_status_codes_from_retry=self._exclude_status_codes_from_retry,
79
+ use_data_streaming=self._use_data_streaming,
80
+ send_data_as_chunked=self._send_data_as_chunked,
81
+ compress=self._compress,
82
+ throw_exception_on_error=self._throw_exception_on_error,
83
+ log_all_url_results=self._log_all_response_urls,
84
+ access_token=self._access_token,
85
+ access_token_expiry_date=self._access_token_expiry_date,
86
+ ) as client:
87
+ response: RetryableAioHttpResponse = await client.patch(
88
+ url=full_uri.url, json=deserialized_data, headers=headers
89
+ )
90
+ response_status = response.status
91
+ response_text = await response.get_text_async()
92
+ request_id = response.response_headers.get("X-Request-ID", None)
93
+ self._internal_logger.debug(f"X-Request-ID={request_id}")
94
+
95
+ if response_status == 200:
96
+ if self._logger:
97
+ self._logger.info(f"Successfully updated: {full_uri}")
98
+ elif response_status == 404:
99
+ if self._logger:
100
+ self._logger.info(f"Request resource was not found: {full_uri}")
101
+ else:
102
+ # other HTTP errors
103
+ self._internal_logger.info(f"PATCH response for {full_uri.url}: {response_status}")
104
+ except Exception as e:
105
+ raise FhirSenderException(
106
+ request_id=request_id,
107
+ url=full_uri.url,
108
+ headers=headers,
109
+ json_data=data,
110
+ response_text=response_text,
111
+ response_status_code=response_status,
112
+ exception=e,
113
+ variables=FhirClientLogger.get_variables_to_log(vars(self)),
114
+ message=f"Error: {e}",
115
+ elapsed_time=time.time() - start_time,
116
+ ) from e
117
+ # check if response is json
118
+ if response_text:
119
+ try:
120
+ responses = json.loads(response_text)
121
+ except ValueError as e:
122
+ responses = {"issue": str(e)}
91
123
  else:
92
- # other HTTP errors
93
- self._internal_logger.info(f"PATCH response for {full_uri.url}: {response_status}")
94
- except Exception as e:
95
- raise FhirSenderException(
96
- request_id=request_id,
97
- url=full_uri.url,
98
- headers=headers,
99
- json_data=data,
100
- response_text=response_text,
101
- response_status_code=response_status,
102
- exception=e,
103
- variables=FhirClientLogger.get_variables_to_log(vars(self)),
104
- message=f"Error: {e}",
105
- elapsed_time=time.time() - start_time,
106
- ) from e
107
- # check if response is json
108
- if response_text:
109
- try:
110
- responses = json.loads(response_text)
111
- except ValueError as e:
112
- responses = {"issue": str(e)}
113
- else:
114
- responses = {}
115
- return FhirUpdateResponse(
116
- request_id=request_id,
117
- url=full_uri.tostr(),
118
- responses=json.dumps(responses),
119
- error=json.dumps(responses),
120
- access_token=access_token,
121
- status=response_status if response_status else 500,
122
- resource_type=self._resource,
123
- )
124
+ responses = {}
125
+ return FhirUpdateResponse(
126
+ request_id=request_id,
127
+ url=full_uri.tostr(),
128
+ responses=json.dumps(responses),
129
+ error=json.dumps(responses),
130
+ access_token=access_token,
131
+ status=response_status if response_status else 500,
132
+ resource_type=self._resource,
133
+ )
134
+ except Exception as e:
135
+ span.record_exception(e)
136
+ span.set_status(Status(StatusCode.ERROR, str(e)))
137
+ raise
124
138
 
125
139
  def send_patch_request(self, data: str) -> FhirUpdateResponse:
126
140
  """
@@ -3,7 +3,11 @@ from collections.abc import AsyncGenerator
3
3
  from compressedfhir.fhir.fhir_resource import FhirResource
4
4
  from compressedfhir.fhir.fhir_resource_list import FhirResourceList
5
5
  from furl import furl
6
+ from opentelemetry import trace
7
+ from opentelemetry.trace import Status, StatusCode
6
8
 
9
+ from helix_fhir_client_sdk.open_telemetry.attribute_names import FhirClientSdkOpenTelemetryAttributeNames
10
+ from helix_fhir_client_sdk.open_telemetry.span_names import FhirClientSdkOpenTelemetrySpanNames
7
11
  from helix_fhir_client_sdk.responses.fhir_client_protocol import FhirClientProtocol
8
12
  from helix_fhir_client_sdk.responses.fhir_update_response import FhirUpdateResponse
9
13
  from helix_fhir_client_sdk.structures.get_access_token_result import (
@@ -15,6 +19,8 @@ from helix_fhir_client_sdk.utilities.retryable_aiohttp_client import (
15
19
  )
16
20
  from helix_fhir_client_sdk.validators.async_fhir_validator import AsyncFhirValidator
17
21
 
22
+ TRACER = trace.get_tracer(__name__)
23
+
18
24
 
19
25
  class FhirUpdateMixin(FhirClientProtocol):
20
26
  async def update_single_resource_async(self, *, resource: FhirResource) -> FhirUpdateResponse:
@@ -69,61 +75,69 @@ class FhirUpdateMixin(FhirClientProtocol):
69
75
  raise ValueError("update should have only one id")
70
76
  if not self._resource:
71
77
  raise ValueError("update requires a FHIR resource type")
72
- full_uri: furl = furl(self._url)
73
- full_uri /= self._resource
74
- full_uri /= id_ or self._id
75
- # set up headers
76
- headers = {"Content-Type": "application/fhir+json"}
77
- headers.update(self._additional_request_headers)
78
- self._internal_logger.debug(f"Request headers: {headers}")
79
-
80
- access_token_result: GetAccessTokenResult = await self.get_access_token_async()
81
- access_token: str | None = access_token_result.access_token
82
- # set access token in request if present
83
- if access_token:
84
- headers["Authorization"] = f"Bearer {access_token}"
85
-
86
- if self._validation_server_url:
87
- await AsyncFhirValidator.validate_fhir_resource(
88
- fn_get_session=lambda: self.create_http_session(),
89
- json_data=json_data,
90
- resource_name=self._resource,
91
- validation_server_url=self._validation_server_url,
92
- access_token=access_token,
93
- )
94
-
95
- # actually make the request
96
- async with RetryableAioHttpClient(
97
- fn_get_session=lambda: self.create_http_session(),
98
- refresh_token_func=self._refresh_token_function,
99
- tracer_request_func=self._trace_request_function,
100
- retries=self._retry_count,
101
- exclude_status_codes_from_retry=self._exclude_status_codes_from_retry,
102
- use_data_streaming=self._use_data_streaming,
103
- send_data_as_chunked=self._send_data_as_chunked,
104
- compress=self._compress,
105
- throw_exception_on_error=self._throw_exception_on_error,
106
- log_all_url_results=self._log_all_response_urls,
107
- access_token=self._access_token,
108
- access_token_expiry_date=self._access_token_expiry_date,
109
- persistent_session=self._persistent_session,
110
- ) as client:
111
- response = await client.put(url=full_uri.url, data=json_data, headers=headers)
112
- request_id = response.response_headers.get("X-Request-ID", None)
113
- self._internal_logger.debug(f"X-Request-ID={request_id}")
114
- if response.status == 200:
115
- if self._logger:
116
- self._logger.info(f"Successfully updated: {full_uri}")
117
-
118
- return FhirUpdateResponse(
119
- request_id=request_id,
120
- url=full_uri.tostr(),
121
- responses=await response.get_text_async(),
122
- error=f"{response.status}" if not response.status == 200 else None,
123
- access_token=access_token,
124
- status=response.status,
125
- resource_type=self._resource,
126
- )
78
+
79
+ with TRACER.start_as_current_span(FhirClientSdkOpenTelemetrySpanNames.UPDATE) as span:
80
+ span.set_attribute(FhirClientSdkOpenTelemetryAttributeNames.URL, self._url or "")
81
+ span.set_attribute(FhirClientSdkOpenTelemetryAttributeNames.RESOURCE, self._resource or "")
82
+ try:
83
+ full_uri: furl = furl(self._url)
84
+ full_uri /= self._resource
85
+ full_uri /= id_ or self._id
86
+ # set up headers
87
+ headers = {"Content-Type": "application/fhir+json"}
88
+ headers.update(self._additional_request_headers)
89
+ self._internal_logger.debug(f"Request headers: {headers}")
90
+
91
+ access_token_result: GetAccessTokenResult = await self.get_access_token_async()
92
+ access_token: str | None = access_token_result.access_token
93
+ # set access token in request if present
94
+ if access_token:
95
+ headers["Authorization"] = f"Bearer {access_token}"
96
+
97
+ if self._validation_server_url:
98
+ await AsyncFhirValidator.validate_fhir_resource(
99
+ fn_get_session=lambda: self.create_http_session(),
100
+ json_data=json_data,
101
+ resource_name=self._resource,
102
+ validation_server_url=self._validation_server_url,
103
+ access_token=access_token,
104
+ )
105
+
106
+ # actually make the request
107
+ async with RetryableAioHttpClient(
108
+ fn_get_session=lambda: self.create_http_session(),
109
+ refresh_token_func=self._refresh_token_function,
110
+ tracer_request_func=self._trace_request_function,
111
+ retries=self._retry_count,
112
+ exclude_status_codes_from_retry=self._exclude_status_codes_from_retry,
113
+ use_data_streaming=self._use_data_streaming,
114
+ send_data_as_chunked=self._send_data_as_chunked,
115
+ compress=self._compress,
116
+ throw_exception_on_error=self._throw_exception_on_error,
117
+ log_all_url_results=self._log_all_response_urls,
118
+ access_token=self._access_token,
119
+ access_token_expiry_date=self._access_token_expiry_date,
120
+ ) as client:
121
+ response = await client.put(url=full_uri.url, data=json_data, headers=headers)
122
+ request_id = response.response_headers.get("X-Request-ID", None)
123
+ self._internal_logger.debug(f"X-Request-ID={request_id}")
124
+ if response.status == 200:
125
+ if self._logger:
126
+ self._logger.info(f"Successfully updated: {full_uri}")
127
+
128
+ return FhirUpdateResponse(
129
+ request_id=request_id,
130
+ url=full_uri.tostr(),
131
+ responses=await response.get_text_async(),
132
+ error=f"{response.status}" if not response.status == 200 else None,
133
+ access_token=access_token,
134
+ status=response.status,
135
+ resource_type=self._resource,
136
+ )
137
+ except Exception as e:
138
+ span.record_exception(e)
139
+ span.set_status(Status(StatusCode.ERROR, str(e)))
140
+ raise
127
141
 
128
142
  def update(self, json_data: str) -> FhirUpdateResponse:
129
143
  """
@@ -2,3 +2,6 @@ class FhirClientSdkOpenTelemetryAttributeNames:
2
2
  """Constants for OpenTelemetry attribute names used in the FHIR Client SDK."""
3
3
 
4
4
  URL: str = "fhir.client_sdk.url"
5
+ RESOURCE: str = "fhir.client_sdk.resource"
6
+ JSON_DATA_COUNT: str = "fhir.client_sdk.json_data.count"
7
+ BATCH_SIZE: str = "fhir.client_sdk.batch.size"
@@ -6,3 +6,7 @@ class FhirClientSdkOpenTelemetrySpanNames:
6
6
  GET_ACCESS_TOKEN: str = "fhir.client_sdk.access_token.get"
7
7
  HTTP_GET: str = "fhir.client_sdk.http.get"
8
8
  HANDLE_RESPONSE: str = "fhir.client_sdk.handle_response"
9
+ DELETE: str = "fhir.client_sdk.delete"
10
+ UPDATE: str = "fhir.client_sdk.update"
11
+ PATCH: str = "fhir.client_sdk.patch"
12
+ MERGE: str = "fhir.client_sdk.merge"
@@ -130,7 +130,6 @@ class RequestQueueMixin(ABC, FhirClientProtocol):
130
130
  log_all_url_results=self._log_all_response_urls,
131
131
  access_token=self._access_token,
132
132
  access_token_expiry_date=self._access_token_expiry_date,
133
- persistent_session=self._persistent_session,
134
133
  ) as client:
135
134
  while next_url:
136
135
  # set access token in request if present
@@ -299,7 +298,6 @@ class RequestQueueMixin(ABC, FhirClientProtocol):
299
298
  log_all_url_results=self._log_all_response_urls,
300
299
  access_token=self._access_token,
301
300
  access_token_expiry_date=self._access_token_expiry_date,
302
- persistent_session=self._persistent_session,
303
301
  ) as client:
304
302
  while next_url:
305
303
  # set access token in request if present
@@ -1,5 +1,5 @@
1
1
  import uuid
2
- from collections.abc import AsyncGenerator
2
+ from collections.abc import AsyncGenerator, Callable
3
3
  from datetime import datetime
4
4
  from logging import Logger
5
5
  from threading import Lock
@@ -89,8 +89,6 @@ class FhirClientProtocol(Protocol):
89
89
  _send_data_as_chunked: bool = False
90
90
  _last_page_lock: Lock
91
91
 
92
- _persistent_session: ClientSession | None = None
93
-
94
92
  _use_post_for_search: bool = False
95
93
 
96
94
  _accept: str
@@ -131,6 +129,9 @@ class FhirClientProtocol(Protocol):
131
129
  _max_concurrent_requests: int | None
132
130
  """ maximum number of concurrent requests to make to the FHIR server """
133
131
 
132
+ _fn_create_http_session: Callable[[], ClientSession] | None
133
+ """ optional callable to create HTTP sessions """
134
+
134
135
  async def get_access_token_async(self) -> GetAccessTokenResult: ...
135
136
 
136
137
  async def _send_fhir_request_async(
@@ -214,4 +215,4 @@ class FhirClientProtocol(Protocol):
214
215
  # this is just here to tell Python this returns a generator
215
216
  yield None # type: ignore[misc]
216
217
 
217
- def set_persistent_session(self, session: ClientSession | None) -> "FhirClientProtocol": ...
218
+ def use_http_session(self, fn_create_http_session: Callable[[], ClientSession] | None) -> "FhirClientProtocol": ...
@@ -45,11 +45,32 @@ class RetryableAioHttpClient:
45
45
  log_all_url_results: bool = False,
46
46
  access_token: str | None,
47
47
  access_token_expiry_date: datetime | None,
48
- persistent_session: ClientSession | None = None,
49
48
  ) -> None:
50
49
  """
51
- RetryableClient provides a way to make HTTP calls with automatic retry and automatic refreshing of access tokens
50
+ RetryableClient provides a way to make HTTP calls with automatic retry and automatic refreshing of access tokens.
52
51
 
52
+ Session Lifecycle Management:
53
+ - If fn_get_session is None (default): The SDK creates and manages the session lifecycle.
54
+ The session will be automatically closed when exiting the context manager.
55
+ - If fn_get_session is provided: The caller is responsible for managing the session lifecycle.
56
+ The SDK will NOT close the session - the caller must close it themselves.
57
+
58
+ :param retries: Number of retry attempts for failed requests
59
+ :param timeout_in_seconds: Timeout for HTTP requests
60
+ :param backoff_factor: Factor for exponential backoff between retries
61
+ :param retry_status_codes: HTTP status codes that trigger a retry
62
+ :param refresh_token_func: Function to refresh authentication tokens
63
+ :param tracer_request_func: Function to trace/log requests
64
+ :param fn_get_session: Optional callable that returns a ClientSession. If provided,
65
+ the caller is responsible for closing the session.
66
+ :param exclude_status_codes_from_retry: Status codes to exclude from retry logic
67
+ :param use_data_streaming: Whether to stream response data
68
+ :param compress: Whether to compress request data
69
+ :param send_data_as_chunked: Whether to use chunked transfer encoding
70
+ :param throw_exception_on_error: Whether to raise exceptions on HTTP errors
71
+ :param log_all_url_results: Whether to log all URL results
72
+ :param access_token: Access token for authentication
73
+ :param access_token_expiry_date: Expiry date of the access token
53
74
  """
54
75
  self.retries: int = retries
55
76
  self.timeout_in_seconds: float | None = timeout_in_seconds
@@ -59,6 +80,8 @@ class RetryableAioHttpClient:
59
80
  )
60
81
  self.refresh_token_func_async: RefreshTokenFunction | None = refresh_token_func
61
82
  self.trace_function_async: TraceRequestFunction | None = tracer_request_func
83
+ # Automatically determine if a session is caller-managed based on whether fn_get_session is provided
84
+ self._caller_managed_session: bool = fn_get_session is not None
62
85
  self.fn_get_session: Callable[[], ClientSession] = (
63
86
  fn_get_session if fn_get_session is not None else lambda: ClientSession()
64
87
  )
@@ -71,16 +94,9 @@ class RetryableAioHttpClient:
71
94
  self.log_all_url_results: bool = log_all_url_results
72
95
  self.access_token: str | None = access_token
73
96
  self.access_token_expiry_date: datetime | None = access_token_expiry_date
74
- self.persistent_session: ClientSession | None = persistent_session
75
97
 
76
98
  async def __aenter__(self) -> "RetryableAioHttpClient":
77
- # Clear a stale persistent session if closed
78
- if self.persistent_session is not None and self.persistent_session.closed:
79
- self.persistent_session = None
80
-
81
- # Use an existing persistent session or create a new one
82
- self.session = self.persistent_session if self.persistent_session is not None else self.fn_get_session()
83
-
99
+ self.session = self.fn_get_session()
84
100
  return self
85
101
 
86
102
  async def __aexit__(
@@ -89,7 +105,9 @@ class RetryableAioHttpClient:
89
105
  exc_val: BaseException | None,
90
106
  exc_tb: type[BaseException] | None | None,
91
107
  ) -> None:
92
- if not self.persistent_session and self.session is not None:
108
+ # Only close the session if SDK created it (fn_get_session was not provided)
109
+ # If the caller provided fn_get_session, they are responsible for closing the session
110
+ if not self._caller_managed_session and self.session is not None:
93
111
  await self.session.close()
94
112
 
95
113
  @staticmethod
@@ -123,7 +141,7 @@ class RetryableAioHttpClient:
123
141
  try:
124
142
  if headers:
125
143
  kwargs["headers"] = headers
126
- # if there is no data then remove from kwargs so as not to confuse aiohttp
144
+ # if there is no data, then remove from kwargs so as not to confuse aiohttp
127
145
  if "data" in kwargs and kwargs["data"] is None:
128
146
  del kwargs["data"]
129
147
  # compression and chunked can only be enabled if there is content sent
@@ -407,7 +425,7 @@ class RetryableAioHttpClient:
407
425
  if retry_after_text:
408
426
  # noinspection PyBroadException
409
427
  try:
410
- if retry_after_text.isnumeric(): # it is number of seconds
428
+ if retry_after_text.isnumeric(): # it is a number of seconds
411
429
  await asyncio.sleep(int(retry_after_text))
412
430
  else:
413
431
  wait_till: datetime = datetime.strptime(retry_after_text, "%a, %d %b %Y %H:%M:%S GMT")
@@ -421,7 +439,7 @@ class RetryableAioHttpClient:
421
439
  if time_diff > 0:
422
440
  await asyncio.sleep(time_diff)
423
441
  except Exception:
424
- # if there was some exception parsing the Retry-After header, sleep for 60 seconds
442
+ # if there was some exception, parsing the Retry-After header, sleep for 60 seconds
425
443
  await asyncio.sleep(60)
426
444
  else:
427
445
  await asyncio.sleep(60)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: helix.fhir.client.sdk
3
- Version: 4.2.14
3
+ Version: 4.2.16
4
4
  Summary: helix.fhir.client.sdk
5
5
  Home-page: https://github.com/icanbwell/helix.fhir.client.sdk
6
6
  Author: Imran Qureshi
@@ -111,9 +111,46 @@ response: Optional[FhirGetResponse] = await FhirGetResponse.from_async_generator
111
111
  ```
112
112
 
113
113
  # Data Streaming
114
- For FHIR servers that support data streaming (e.g., b.well FHIR server), you can just set the `use_data_streaming` parameter to stream the data as it i received.
114
+ For FHIR servers that support data streaming (e.g., b.well FHIR server), you can just set the `use_data_streaming` parameter to stream the data as it is received.
115
115
  The data will be streamed in AsyncGenerators as described above.
116
116
 
117
+ # Persistent Sessions (Connection Reuse)
118
+ By default, the SDK creates a new HTTP session for each request. For better performance (~4× faster),
119
+ you can use persistent sessions to reuse connections across multiple requests.
120
+
121
+ **Important**: When you provide a custom session factory using `use_http_session()`, YOU are responsible
122
+ for managing the session lifecycle, including closing it when done. The SDK will NOT automatically close
123
+ user-provided sessions.
124
+
125
+ ```python
126
+ import aiohttp
127
+ from helix_fhir_client_sdk.fhir_client import FhirClient
128
+
129
+ # Create a persistent session for connection reuse
130
+ session = aiohttp.ClientSession()
131
+
132
+ try:
133
+ # Configure FhirClient to use persistent session
134
+ fhir_client = (
135
+ FhirClient()
136
+ .url("https://fhir.example.com")
137
+ .resource("Patient")
138
+ .use_http_session(lambda: session) # User provides session factory
139
+ )
140
+
141
+ # Multiple requests reuse the same connection (~4× performance boost)
142
+ response1 = await fhir_client.get_async()
143
+ response2 = await fhir_client.clone().resource("Observation").get_async()
144
+
145
+ finally:
146
+ # User must close the session when done
147
+ await session.close()
148
+ ```
149
+
150
+ **Session Lifecycle Rules**:
151
+ - **No custom factory** (default): SDK creates and closes the session automatically
152
+ - **Custom factory provided**: User is responsible for closing the session
153
+
117
154
  # Storage Compression
118
155
  The FHIR client SDK supports two types of compression:
119
156