Skip to content

Commit cde03b9

Browse files
authored
Fix blocking I/O to load netrc when creating requests (#11634)
1 parent 302243e commit cde03b9

7 files changed

Lines changed: 227 additions & 62 deletions

File tree

CHANGES/11634.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed blocking I/O in the event loop when using netrc authentication by moving netrc file lookup to an executor -- by :user:`bdraco`.

aiohttp/client.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,10 @@
8686
EMPTY_BODY_METHODS,
8787
BasicAuth,
8888
TimeoutHandle,
89+
basicauth_from_netrc,
8990
frozen_dataclass_decorator,
9091
get_env_proxy_for_url,
92+
netrc_from_env,
9193
sentinel,
9294
strip_auth_from_url,
9395
)
@@ -586,6 +588,20 @@ async def _request(
586588
)
587589
):
588590
auth = self._default_auth
591+
592+
# Try netrc if auth is still None and trust_env is enabled.
593+
# Only check if NETRC environment variable is set to avoid
594+
# creating an expensive executor job unnecessarily.
595+
if (
596+
auth is None
597+
and self._trust_env
598+
and url.host is not None
599+
and os.environ.get("NETRC")
600+
):
601+
auth = await self._loop.run_in_executor(
602+
None, self._get_netrc_auth, url.host
603+
)
604+
589605
# It would be confusing if we support explicit
590606
# Authorization header with auth argument
591607
if auth is not None and hdrs.AUTHORIZATION in headers:
@@ -1131,6 +1147,19 @@ def _prepare_headers(self, headers: LooseHeaders | None) -> "CIMultiDict[str]":
11311147
added_names.add(key)
11321148
return result
11331149

1150+
def _get_netrc_auth(self, host: str) -> BasicAuth | None:
1151+
"""
1152+
Get auth from netrc for the given host.
1153+
1154+
This method is designed to be called in an executor to avoid
1155+
blocking I/O in the event loop.
1156+
"""
1157+
netrc_obj = netrc_from_env()
1158+
try:
1159+
return basicauth_from_netrc(netrc_obj, host)
1160+
except LookupError:
1161+
return None
1162+
11341163
if sys.version_info >= (3, 11) and TYPE_CHECKING:
11351164

11361165
def get(

aiohttp/client_reqrep.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,8 @@
4040
BasicAuth,
4141
HeadersMixin,
4242
TimerNoop,
43-
basicauth_from_netrc,
4443
frozen_dataclass_decorator,
4544
is_expected_content_type,
46-
netrc_from_env,
4745
parse_mimetype,
4846
reify,
4947
sentinel,
@@ -1068,10 +1066,6 @@ def update_auth(self, auth: BasicAuth | None, trust_env: bool = False) -> None:
10681066
"""Set basic auth."""
10691067
if auth is None:
10701068
auth = self.auth
1071-
if auth is None and trust_env and self.url.host is not None:
1072-
netrc_obj = netrc_from_env()
1073-
with contextlib.suppress(LookupError):
1074-
auth = basicauth_from_netrc(netrc_obj, self.url.host)
10751069
if auth is None:
10761070
return
10771071

tests/conftest.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,6 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]:
7171
with blockbuster_ctx(
7272
"aiohttp", excluded_modules=["aiohttp.pytest_plugin", "aiohttp.test_utils"]
7373
) as bb:
74-
# TODO: Fix blocking call in ClientRequest's constructor.
75-
# https://github.com/aio-libs/aiohttp/issues/10435
76-
for func in ["io.TextIOWrapper.read", "os.stat"]:
77-
bb.functions[func].can_block_in("aiohttp/client_reqrep.py", "update_auth")
7874
for func in [
7975
"os.getcwd",
8076
"os.readlink",
@@ -292,6 +288,34 @@ def netrc_contents(
292288
return netrc_file_path
293289

294290

291+
@pytest.fixture
292+
def netrc_default_contents(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
293+
"""Create a temporary netrc file with default test credentials and set NETRC env var."""
294+
netrc_file = tmp_path / ".netrc"
295+
netrc_file.write_text("default login netrc_user password netrc_pass\n")
296+
297+
monkeypatch.setenv("NETRC", str(netrc_file))
298+
299+
return netrc_file
300+
301+
302+
@pytest.fixture
303+
def no_netrc(monkeypatch: pytest.MonkeyPatch) -> None:
304+
"""Ensure NETRC environment variable is not set."""
305+
monkeypatch.delenv("NETRC", raising=False)
306+
307+
308+
@pytest.fixture
309+
def netrc_other_host(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
310+
"""Create a temporary netrc file with credentials for a different host and set NETRC env var."""
311+
netrc_file = tmp_path / ".netrc"
312+
netrc_file.write_text("machine other.example.com login user password pass\n")
313+
314+
monkeypatch.setenv("NETRC", str(netrc_file))
315+
316+
return netrc_file
317+
318+
295319
@pytest.fixture
296320
def start_connection() -> Iterator[mock.Mock]:
297321
with mock.patch(

tests/test_client_functional.py

Lines changed: 91 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ def fname(here: pathlib.Path) -> pathlib.Path:
7070
return here / "conftest.py"
7171

7272

73+
@pytest.fixture
74+
def headers_echo_client(
75+
aiohttp_client: AiohttpClient,
76+
) -> Callable[..., Awaitable[TestClient[web.Request, web.Application]]]:
77+
"""Create a client with an app that echoes request headers as JSON."""
78+
79+
async def factory(**kwargs: Any) -> TestClient[web.Request, web.Application]:
80+
async def handler(request: web.Request) -> web.Response:
81+
return web.json_response({"headers": dict(request.headers)})
82+
83+
app = web.Application()
84+
app.router.add_get("/", handler)
85+
return await aiohttp_client(app, **kwargs)
86+
87+
return factory
88+
89+
7390
async def test_keepalive_two_requests_success(aiohttp_client: AiohttpClient) -> None:
7491
async def handler(request: web.Request) -> web.Response:
7592
body = await request.read()
@@ -3702,29 +3719,25 @@ async def handler(request: web.Request) -> web.Response:
37023719
assert resp.status == 200
37033720

37043721

3705-
async def test_session_auth(aiohttp_client: AiohttpClient) -> None:
3706-
async def handler(request: web.Request) -> web.Response:
3707-
return web.json_response({"headers": dict(request.headers)})
3708-
3709-
app = web.Application()
3710-
app.router.add_get("/", handler)
3711-
3712-
client = await aiohttp_client(app, auth=aiohttp.BasicAuth("login", "pass"))
3722+
async def test_session_auth(
3723+
headers_echo_client: Callable[
3724+
..., Awaitable[TestClient[web.Request, web.Application]]
3725+
],
3726+
) -> None:
3727+
client = await headers_echo_client(auth=aiohttp.BasicAuth("login", "pass"))
37133728

37143729
async with client.get("/") as r:
37153730
assert r.status == 200
37163731
content = await r.json()
37173732
assert content["headers"]["Authorization"] == "Basic bG9naW46cGFzcw=="
37183733

37193734

3720-
async def test_session_auth_override(aiohttp_client: AiohttpClient) -> None:
3721-
async def handler(request: web.Request) -> web.Response:
3722-
return web.json_response({"headers": dict(request.headers)})
3723-
3724-
app = web.Application()
3725-
app.router.add_get("/", handler)
3726-
3727-
client = await aiohttp_client(app, auth=aiohttp.BasicAuth("login", "pass"))
3735+
async def test_session_auth_override(
3736+
headers_echo_client: Callable[
3737+
..., Awaitable[TestClient[web.Request, web.Application]]
3738+
],
3739+
) -> None:
3740+
client = await headers_echo_client(auth=aiohttp.BasicAuth("login", "pass"))
37283741

37293742
async with client.get("/", auth=aiohttp.BasicAuth("other_login", "pass")) as r:
37303743
assert r.status == 200
@@ -3746,30 +3759,77 @@ async def handler(request: web.Request) -> NoReturn:
37463759
await client.get("/", headers=headers)
37473760

37483761

3749-
async def test_session_headers(aiohttp_client: AiohttpClient) -> None:
3750-
async def handler(request: web.Request) -> web.Response:
3751-
return web.json_response({"headers": dict(request.headers)})
3752-
3753-
app = web.Application()
3754-
app.router.add_get("/", handler)
3762+
@pytest.mark.usefixtures("netrc_default_contents")
3763+
async def test_netrc_auth_from_env( # type: ignore[misc]
3764+
headers_echo_client: Callable[
3765+
..., Awaitable[TestClient[web.Request, web.Application]]
3766+
],
3767+
) -> None:
3768+
"""Test that netrc authentication works when NETRC env var is set and trust_env=True."""
3769+
client = await headers_echo_client(trust_env=True)
3770+
async with client.get("/") as r:
3771+
assert r.status == 200
3772+
content = await r.json()
3773+
# Base64 encoded "netrc_user:netrc_pass" is "bmV0cmNfdXNlcjpuZXRyY19wYXNz"
3774+
assert content["headers"]["Authorization"] == "Basic bmV0cmNfdXNlcjpuZXRyY19wYXNz"
37553775

3756-
client = await aiohttp_client(app, headers={"X-Real-IP": "192.168.0.1"})
37573776

3777+
@pytest.mark.usefixtures("no_netrc")
3778+
async def test_netrc_auth_skipped_without_env_var( # type: ignore[misc]
3779+
headers_echo_client: Callable[
3780+
..., Awaitable[TestClient[web.Request, web.Application]]
3781+
],
3782+
) -> None:
3783+
"""Test that netrc authentication is skipped when NETRC env var is not set."""
3784+
client = await headers_echo_client(trust_env=True)
37583785
async with client.get("/") as r:
37593786
assert r.status == 200
37603787
content = await r.json()
3761-
assert content["headers"]["X-Real-IP"] == "192.168.0.1"
3788+
# No Authorization header should be present
3789+
assert "Authorization" not in content["headers"]
37623790

37633791

3764-
async def test_session_headers_merge(aiohttp_client: AiohttpClient) -> None:
3765-
async def handler(request: web.Request) -> web.Response:
3766-
return web.json_response({"headers": dict(request.headers)})
3792+
@pytest.mark.usefixtures("netrc_default_contents")
3793+
async def test_netrc_auth_overridden_by_explicit_auth( # type: ignore[misc]
3794+
headers_echo_client: Callable[
3795+
..., Awaitable[TestClient[web.Request, web.Application]]
3796+
],
3797+
) -> None:
3798+
"""Test that explicit auth parameter overrides netrc authentication."""
3799+
client = await headers_echo_client(trust_env=True)
3800+
# Make request with explicit auth (should override netrc)
3801+
async with client.get(
3802+
"/", auth=aiohttp.BasicAuth("explicit_user", "explicit_pass")
3803+
) as r:
3804+
assert r.status == 200
3805+
content = await r.json()
3806+
# Base64 encoded "explicit_user:explicit_pass" is "ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
3807+
assert (
3808+
content["headers"]["Authorization"]
3809+
== "Basic ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
3810+
)
37673811

3768-
app = web.Application()
3769-
app.router.add_get("/", handler)
37703812

3771-
client = await aiohttp_client(
3772-
app, headers=[("X-Real-IP", "192.168.0.1"), ("X-Sent-By", "requests")]
3813+
async def test_session_headers(
3814+
headers_echo_client: Callable[
3815+
..., Awaitable[TestClient[web.Request, web.Application]]
3816+
],
3817+
) -> None:
3818+
client = await headers_echo_client(headers={"X-Real-IP": "192.168.0.1"})
3819+
3820+
async with client.get("/") as r:
3821+
assert r.status == 200
3822+
content = await r.json()
3823+
assert content["headers"]["X-Real-IP"] == "192.168.0.1"
3824+
3825+
3826+
async def test_session_headers_merge(
3827+
headers_echo_client: Callable[
3828+
..., Awaitable[TestClient[web.Request, web.Application]]
3829+
],
3830+
) -> None:
3831+
client = await headers_echo_client(
3832+
headers=[("X-Real-IP", "192.168.0.1"), ("X-Sent-By", "requests")]
37733833
)
37743834

37753835
async with client.get("/", headers={"X-Sent-By": "aiohttp"}) as r:

tests/test_client_request.py

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from yarl import URL
1515

1616
import aiohttp
17-
from aiohttp import BaseConnector, hdrs, helpers, payload
17+
from aiohttp import BaseConnector, hdrs, payload
1818
from aiohttp.abc import AbstractStreamWriter
1919
from aiohttp.base_protocol import BaseProtocol
2020
from aiohttp.client_exceptions import ClientConnectionError
@@ -1574,26 +1574,6 @@ def test_gen_default_accept_encoding(
15741574
assert _gen_default_accept_encoding() == expected
15751575

15761576

1577-
@pytest.mark.parametrize(
1578-
("netrc_contents", "expected_auth"),
1579-
[
1580-
(
1581-
"machine example.com login username password pass\n",
1582-
helpers.BasicAuth("username", "pass"),
1583-
)
1584-
],
1585-
indirect=("netrc_contents",),
1586-
)
1587-
@pytest.mark.usefixtures("netrc_contents")
1588-
def test_basicauth_from_netrc_present( # type: ignore[misc]
1589-
make_request: _RequestMaker,
1590-
expected_auth: helpers.BasicAuth,
1591-
) -> None:
1592-
"""Test appropriate Authorization header is sent when netrc is not empty."""
1593-
req = make_request("get", "http://example.com", trust_env=True)
1594-
assert req.headers[hdrs.AUTHORIZATION] == expected_auth.encode()
1595-
1596-
15971577
@pytest.mark.parametrize(
15981578
"netrc_contents",
15991579
("machine example.com login username password pass\n",),

0 commit comments

Comments
 (0)