Skip to content

Commit 9bb4c32

Browse files
committed
APP-5243: Migrate to httpx (goodbye requests)
- Removed requirements files - Updated GitHub workflows for Docker image builds to use `uv sync` (without dev dependencies) - Breaking change: Dropped support for Python 3.8 (httpx-retries requires Python ≥ 3.9)
1 parent 7a03f61 commit 9bb4c32

File tree

9 files changed

+170
-1022
lines changed

9 files changed

+170
-1022
lines changed

.github/workflows/pyatlan-publish.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- name: Install uv
3232
uses: astral-sh/setup-uv@v6
3333
- name: Install dependencies
34-
run: uv sync --extra dev
34+
run: uv sync
3535
- name: check tag
3636
id: check-tag
3737
run: uv run python check_tag.py

pyatlan/client/asset.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from warnings import warn
2727

28-
import requests
28+
import httpx
2929
from pydantic.v1 import (
3030
StrictStr,
3131
ValidationError,
@@ -858,7 +858,7 @@ def _wait_till_deleted(self, asset: Asset):
858858
asset = self.retrieve_minimal(guid=asset.guid, asset_type=Asset)
859859
if asset.status == EntityStatus.DELETED:
860860
return
861-
except requests.exceptions.RetryError as err:
861+
except httpx.RetryError as err:
862862
raise ErrorCode.RETRY_OVERRUN.exception_with_parameters() from err
863863

864864
@validate_arguments

pyatlan/client/atlan.py

Lines changed: 32 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from urllib.parse import urljoin
1919
from warnings import warn
2020

21-
import requests
21+
import httpx
22+
from httpx_retries import Retry, RetryTransport
2223
from pydantic.v1 import (
2324
BaseSettings,
2425
HttpUrl,
@@ -27,8 +28,6 @@
2728
constr,
2829
validate_arguments,
2930
)
30-
from requests.adapters import HTTPAdapter
31-
from urllib3.util.retry import Retry
3231

3332
from pyatlan.cache.atlan_tag_cache import AtlanTagCache
3433
from pyatlan.cache.connection_cache import ConnectionCache
@@ -41,8 +40,8 @@
4140
from pyatlan.client.admin import AdminClient
4241
from pyatlan.client.asset import A, AssetClient, IndexSearchResults, LineageListResults
4342
from pyatlan.client.audit import AuditClient
44-
from pyatlan.client.common import CONNECTION_RETRY, HTTP_PREFIX, HTTPS_PREFIX
45-
from pyatlan.client.constants import EVENT_STREAM, GET_TOKEN, PARSE_QUERY, UPLOAD_IMAGE
43+
from pyatlan.client.common import CONNECTION_RETRY
44+
from pyatlan.client.constants import EVENT_STREAM, PARSE_QUERY, UPLOAD_IMAGE
4645
from pyatlan.client.contract import ContractClient
4746
from pyatlan.client.credential import CredentialClient
4847
from pyatlan.client.file import FileClient
@@ -109,7 +108,6 @@ def get_adapter() -> logging.LoggerAdapter:
109108
backoff_factor=1,
110109
status_forcelist=[403, 429, 500, 502, 503, 504],
111110
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
112-
raise_on_status=False,
113111
# When response.status is in `status_forcelist`
114112
# and the "Retry-After" header is present, the retry mechanism
115113
# will use the header's value to delay the next API call.
@@ -124,28 +122,14 @@ def log_response(response, *args, **kwargs):
124122
LOGGER.debug("URL: %s", response.request.url)
125123

126124

127-
def get_session():
128-
session = requests.session()
129-
session.headers.update(
130-
{
131-
"x-atlan-agent": "sdk",
132-
"x-atlan-agent-id": "python",
133-
"x-atlan-client-origin": "product_sdk",
134-
"User-Agent": f"Atlan-PythonSDK/{VERSION}",
135-
}
136-
)
137-
session.hooks["response"].append(log_response)
138-
return session
139-
140-
141125
class AtlanClient(BaseSettings):
142126
base_url: Union[Literal["INTERNAL"], HttpUrl]
143127
api_key: str
144128
connect_timeout: float = 30.0 # 30 secs
145129
read_timeout: float = 900.0 # 15 mins
146130
retry: Retry = DEFAULT_RETRY
147131
_401_has_retried: ContextVar[bool] = ContextVar("_401_has_retried", default=False)
148-
_session: requests.Session = PrivateAttr(default_factory=get_session)
132+
_session: httpx.Client = PrivateAttr(default_factory=lambda: httpx.Client())
149133
_request_params: dict = PrivateAttr()
150134
_user_id: Optional[str] = PrivateAttr(default=None)
151135
_workflow_client: Optional[WorkflowClient] = PrivateAttr(default=None)
@@ -185,10 +169,17 @@ def __init__(self, **data):
185169
"authorization": f"Bearer {self.api_key}",
186170
}
187171
}
188-
session = self._session
189-
adapter = HTTPAdapter(max_retries=self.retry)
190-
session.mount(HTTPS_PREFIX, adapter)
191-
session.mount(HTTP_PREFIX, adapter)
172+
# Configure httpx client with retry transport
173+
self._session = httpx.Client(
174+
transport=RetryTransport(retry=self.retry),
175+
headers={
176+
"x-atlan-agent": "sdk",
177+
"x-atlan-agent-id": "python",
178+
"x-atlan-client-origin": "product_sdk",
179+
"User-Agent": f"Atlan-PythonSDK/{VERSION}",
180+
},
181+
event_hooks={"response": [log_response]},
182+
)
192183
self._401_has_retried.set(False)
193184

194185
@property
@@ -438,21 +429,24 @@ def _call_api_internal(
438429
token = request_id_var.set(str(uuid.uuid4()))
439430
try:
440431
params["headers"]["X-Atlan-Request-Id"] = request_id_var.get()
432+
timeout = httpx.Timeout(
433+
None, connect=self.connect_timeout, read=self.read_timeout
434+
)
441435
if binary_data:
442436
response = self._session.request(
443437
api.method.value,
444438
path,
445439
data=binary_data,
446440
**params,
447-
timeout=(self.connect_timeout, self.read_timeout),
441+
timeout=timeout,
448442
)
449443
elif api.consumes == EVENT_STREAM and api.produces == EVENT_STREAM:
450444
response = self._session.request(
451445
api.method.value,
452446
path,
453447
**params,
454448
stream=True,
455-
timeout=(self.connect_timeout, self.read_timeout),
449+
timeout=timeout,
456450
)
457451
if download_file_path:
458452
return self._handle_file_download(response.raw, download_file_path)
@@ -461,7 +455,7 @@ def _call_api_internal(
461455
api.method.value,
462456
path,
463457
**params,
464-
timeout=(self.connect_timeout, self.read_timeout),
458+
timeout=timeout,
465459
)
466460
if response is not None:
467461
LOGGER.debug("HTTP Status: %s", response.status_code)
@@ -527,10 +521,7 @@ def _call_api_internal(
527521
)
528522
LOGGER.debug("response: %s", response_)
529523
return response_
530-
except (
531-
requests.exceptions.JSONDecodeError,
532-
json.decoder.JSONDecodeError,
533-
) as e:
524+
except (json.decoder.JSONDecodeError,) as e:
534525
raise ErrorCode.JSON_ERROR.exception_with_parameters(
535526
response.text, response.status_code, str(e)
536527
) from e
@@ -1827,12 +1818,11 @@ def max_retries(
18271818
) -> Generator[None, None, None]:
18281819
"""Creates a context manger that can used to temporarily change parameters used for retrying connnections.
18291820
The original Retry information will be restored when the context is exited."""
1830-
if self.base_url == "INTERNAL":
1831-
adapter = self._session.adapters[HTTP_PREFIX]
1832-
else:
1833-
adapter = self._session.adapters[HTTPS_PREFIX]
1834-
current_max = adapter.max_retries # type: ignore[attr-defined]
1835-
adapter.max_retries = max_retries # type: ignore[attr-defined]
1821+
# Store current transport and create new one with updated retries
1822+
current_transport = self._session._transport
1823+
new_transport = RetryTransport(retry=max_retries)
1824+
self._session._transport = new_transport
1825+
18361826
LOGGER.debug(
18371827
"max_retries set to total: %s force_list: %s",
18381828
max_retries.total,
@@ -1842,16 +1832,13 @@ def max_retries(
18421832
LOGGER.debug("Entering max_retries")
18431833
yield None
18441834
LOGGER.debug("Exiting max_retries")
1845-
except requests.exceptions.RetryError as err:
1835+
except httpx.TransportError as err:
18461836
LOGGER.exception("Exception in max retries")
18471837
raise ErrorCode.RETRY_OVERRUN.exception_with_parameters() from err
18481838
finally:
1849-
adapter.max_retries = current_max # type: ignore[attr-defined]
1850-
LOGGER.debug(
1851-
"max_retries restored to total: %s force_list: %s",
1852-
adapter.max_retries.total, # type: ignore[attr-defined]
1853-
adapter.max_retries.status_forcelist, # type: ignore[attr-defined]
1854-
)
1839+
# Restore original transport
1840+
self._session._transport = current_transport
1841+
LOGGER.debug("max_retries restored %s", self._session._transport.retry)
18551842

18561843

18571844
@contextlib.contextmanager

pyatlan/client/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import Any, Generator, Protocol, runtime_checkable
66

7-
from urllib3.util.retry import Retry
7+
from httpx_retries import Retry
88

99
HTTPS_PREFIX = "https://"
1010
HTTP_PREFIX = "http://"

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ maintainers = [
1616
]
1717
keywords = ["atlan", "client"]
1818
classifiers = [
19-
"Programming Language :: Python :: 3.8",
2019
"Programming Language :: Python :: 3.9",
2120
"Programming Language :: Python :: 3.10",
2221
"Programming Language :: Python :: 3.11",
@@ -26,18 +25,18 @@ classifiers = [
2625
"Operating System :: OS Independent",
2726
"Development Status :: 5 - Production/Stable",
2827
]
29-
requires-python = ">=3.8"
28+
requires-python = ">=3.9"
3029
dependencies = [
31-
"requests~=2.32.3",
3230
"pydantic~=2.10.6",
3331
"jinja2~=3.1.6",
3432
"tenacity~=9.0.0",
35-
"urllib3>=1.26.0,<3",
3633
"lazy_loader~=0.4",
3734
"nanoid~=2.0.0",
3835
"pytz~=2025.1",
3936
"python-dateutil~=2.9.0.post0",
4037
"PyYAML~=6.0.2",
38+
"httpx>=0.28.1",
39+
"httpx-retries>=0.4.0",
4140
]
4241

4342
[project.urls]

tests/integration/test_index_search.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from typing import Generator, Set
88
from unittest.mock import patch
99

10+
import httpx
1011
import pytest
11-
import requests.exceptions
12-
from urllib3 import Retry
12+
from httpx_retries import Retry
1313

1414
from pyatlan.cache.source_tag_cache import SourceTagName
1515
from pyatlan.client.asset import LOGGER, IndexSearchResults, Persona, Purpose
@@ -847,8 +847,8 @@ def test_read_timeout(client: AtlanClient):
847847
client=client, read_timeout=0.1, retry=Retry(total=0)
848848
) as timed_client:
849849
with pytest.raises(
850-
requests.exceptions.ReadTimeout,
851-
match=".Read timed out\. \(read timeout=0\.1\)", # noqa W605
850+
httpx.ReadTimeout,
851+
match="The read operation timed out",
852852
):
853853
timed_client.asset.search(criteria=request)
854854

@@ -859,7 +859,7 @@ def test_connect_timeout(client: AtlanClient):
859859
client=client, connect_timeout=0.0001, retry=Retry(total=0)
860860
) as timed_client:
861861
with pytest.raises(
862-
requests.exceptions.ConnectionError,
863-
match=".(timed out\. \(connect timeout=0\.0001\))|(Failed to establish a new connection.)", # noqa W605
862+
httpx.ConnectTimeout,
863+
match="timed out",
864864
):
865865
timed_client.asset.search(criteria=request)

tests/unit/test_base_vcr_json.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import httpx
12
import pytest
2-
import requests
33

44
from pyatlan.test_utils.base_vcr import BaseVCR
55

@@ -28,7 +28,7 @@ def test_httpbin_get(self):
2828
Test a simple GET request to httpbin.
2929
"""
3030
url = f"{self.BASE_URL}/get"
31-
response = requests.get(url, params={"test": "value"})
31+
response = httpx.get(url, params={"test": "value"})
3232
assert response.status_code == 200
3333
assert response.json()["args"]["test"] == "value"
3434

@@ -39,7 +39,7 @@ def test_httpbin_post(self):
3939
"""
4040
url = f"{self.BASE_URL}/post"
4141
payload = {"name": "atlan", "type": "integration-test"}
42-
response = requests.post(url, json=payload)
42+
response = httpx.post(url, json=payload)
4343
assert response.status_code == 200
4444
assert response.json()["json"] == payload
4545

@@ -50,7 +50,7 @@ def test_httpbin_put(self):
5050
"""
5151
url = f"{self.BASE_URL}/put"
5252
payload = {"update": "value"}
53-
response = requests.put(url, json=payload)
53+
response = httpx.put(url, json=payload)
5454
assert response.status_code == 200
5555
assert response.json()["json"] == payload
5656

@@ -60,6 +60,6 @@ def test_httpbin_delete(self):
6060
Test a simple DELETE request to httpbin.
6161
"""
6262
url = f"{self.BASE_URL}/delete"
63-
response = requests.delete(url)
63+
response = httpx.delete(url)
6464
assert response.status_code == 200
6565
assert response.json()["args"] == {}

tests/unit/test_base_vcr_yaml.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import httpx
12
import pytest
2-
import requests
33

44
from pyatlan.test_utils.base_vcr import BaseVCR
55

@@ -19,7 +19,7 @@ def test_httpbin_get(self):
1919
Test a simple GET request to httpbin.
2020
"""
2121
url = f"{self.BASE_URL}/get"
22-
response = requests.get(url, params={"test": "value"})
22+
response = httpx.get(url, params={"test": "value"})
2323

2424
assert response.status_code == 200
2525
assert response.json()["args"]["test"] == "value"
@@ -31,7 +31,7 @@ def test_httpbin_post(self):
3131
"""
3232
url = f"{self.BASE_URL}/post"
3333
payload = {"name": "atlan", "type": "integration-test"}
34-
response = requests.post(url, json=payload)
34+
response = httpx.post(url, json=payload)
3535

3636
assert response.status_code == 200
3737
assert response.json()["json"] == payload
@@ -43,7 +43,7 @@ def test_httpbin_put(self):
4343
"""
4444
url = f"{self.BASE_URL}/put"
4545
payload = {"update": "value"}
46-
response = requests.put(url, json=payload)
46+
response = httpx.put(url, json=payload)
4747

4848
assert response.status_code == 200
4949
assert response.json()["json"] == payload
@@ -54,7 +54,7 @@ def test_httpbin_delete(self):
5454
Test a simple DELETE request to httpbin.
5555
"""
5656
url = f"{self.BASE_URL}/delete"
57-
response = requests.delete(url)
57+
response = httpx.delete(url)
5858

5959
assert response.status_code == 200
6060
# HTTPBin returns an empty JSON object for DELETE

0 commit comments

Comments
 (0)