Skip to content

Commit e23086f

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 b995816 commit e23086f

File tree

11 files changed

+169
-1053
lines changed

11 files changed

+169
-1053
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: 31 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from urllib.parse import urljoin
1818
from warnings import warn
1919

20-
import requests
20+
import httpx
21+
from httpx_retries import Retry, RetryTransport
2122
from pydantic.v1 import (
2223
BaseSettings,
2324
HttpUrl,
@@ -26,8 +27,6 @@
2627
constr,
2728
validate_arguments,
2829
)
29-
from requests.adapters import HTTPAdapter
30-
from urllib3.util.retry import Retry
3130

3231
from pyatlan.cache.atlan_tag_cache import AtlanTagCache
3332
from pyatlan.cache.connection_cache import ConnectionCache
@@ -40,7 +39,7 @@
4039
from pyatlan.client.admin import AdminClient
4140
from pyatlan.client.asset import A, AssetClient, IndexSearchResults, LineageListResults
4241
from pyatlan.client.audit import AuditClient
43-
from pyatlan.client.common import CONNECTION_RETRY, HTTP_PREFIX, HTTPS_PREFIX
42+
from pyatlan.client.common import CONNECTION_RETRY
4443
from pyatlan.client.constants import EVENT_STREAM, PARSE_QUERY, UPLOAD_IMAGE
4544
from pyatlan.client.contract import ContractClient
4645
from pyatlan.client.credential import CredentialClient
@@ -108,7 +107,6 @@ def get_adapter() -> logging.LoggerAdapter:
108107
backoff_factor=1,
109108
status_forcelist=[403, 429, 500, 502, 503, 504],
110109
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
111-
raise_on_status=False,
112110
# When response.status is in `status_forcelist`
113111
# and the "Retry-After" header is present, the retry mechanism
114112
# will use the header's value to delay the next API call.
@@ -123,28 +121,14 @@ def log_response(response, *args, **kwargs):
123121
LOGGER.debug("URL: %s", response.request.url)
124122

125123

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

193184
@property
@@ -371,21 +362,24 @@ def _call_api_internal(
371362
token = request_id_var.set(str(uuid.uuid4()))
372363
try:
373364
params["headers"]["X-Atlan-Request-Id"] = request_id_var.get()
365+
timeout = httpx.Timeout(
366+
None, connect=self.connect_timeout, read=self.read_timeout
367+
)
374368
if binary_data:
375369
response = self._session.request(
376370
api.method.value,
377371
path,
378372
data=binary_data,
379373
**params,
380-
timeout=(self.connect_timeout, self.read_timeout),
374+
timeout=timeout,
381375
)
382376
elif api.consumes == EVENT_STREAM and api.produces == EVENT_STREAM:
383377
response = self._session.request(
384378
api.method.value,
385379
path,
386380
**params,
387381
stream=True,
388-
timeout=(self.connect_timeout, self.read_timeout),
382+
timeout=timeout,
389383
)
390384
if download_file_path:
391385
return self._handle_file_download(response.raw, download_file_path)
@@ -394,7 +388,7 @@ def _call_api_internal(
394388
api.method.value,
395389
path,
396390
**params,
397-
timeout=(self.connect_timeout, self.read_timeout),
391+
timeout=timeout,
398392
)
399393
if response is not None:
400394
LOGGER.debug("HTTP Status: %s", response.status_code)
@@ -460,10 +454,7 @@ def _call_api_internal(
460454
)
461455
LOGGER.debug("response: %s", response_)
462456
return response_
463-
except (
464-
requests.exceptions.JSONDecodeError,
465-
json.decoder.JSONDecodeError,
466-
) as e:
457+
except (json.decoder.JSONDecodeError,) as e:
467458
raise ErrorCode.JSON_ERROR.exception_with_parameters(
468459
response.text, response.status_code, str(e)
469460
) from e
@@ -1760,12 +1751,11 @@ def max_retries(
17601751
) -> Generator[None, None, None]:
17611752
"""Creates a context manger that can used to temporarily change parameters used for retrying connnections.
17621753
The original Retry information will be restored when the context is exited."""
1763-
if self.base_url == "INTERNAL":
1764-
adapter = self._session.adapters[HTTP_PREFIX]
1765-
else:
1766-
adapter = self._session.adapters[HTTPS_PREFIX]
1767-
current_max = adapter.max_retries # type: ignore[attr-defined]
1768-
adapter.max_retries = max_retries # type: ignore[attr-defined]
1754+
# Store current transport and create new one with updated retries
1755+
current_transport = self._session._transport
1756+
new_transport = RetryTransport(retry=max_retries)
1757+
self._session._transport = new_transport
1758+
17691759
LOGGER.debug(
17701760
"max_retries set to total: %s force_list: %s",
17711761
max_retries.total,
@@ -1775,16 +1765,13 @@ def max_retries(
17751765
LOGGER.debug("Entering max_retries")
17761766
yield None
17771767
LOGGER.debug("Exiting max_retries")
1778-
except requests.exceptions.RetryError as err:
1768+
except httpx.TransportError as err:
17791769
LOGGER.exception("Exception in max retries")
17801770
raise ErrorCode.RETRY_OVERRUN.exception_with_parameters() from err
17811771
finally:
1782-
adapter.max_retries = current_max # type: ignore[attr-defined]
1783-
LOGGER.debug(
1784-
"max_retries restored to total: %s force_list: %s",
1785-
adapter.max_retries.total, # type: ignore[attr-defined]
1786-
adapter.max_retries.status_forcelist, # type: ignore[attr-defined]
1787-
)
1772+
# Restore original transport
1773+
self._session._transport = current_transport
1774+
LOGGER.debug("max_retries restored %s", self._session._transport.retry)
17881775

17891776

17901777
@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]

requirements-dev.txt

Lines changed: 0 additions & 22 deletions
This file was deleted.

requirements.txt

Lines changed: 0 additions & 10 deletions
This file was deleted.

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)