Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0b9b763
Fix blocking I/O to load netrc when creating requests
bdraco Oct 11, 2025
2ff3b70
changes
bdraco Oct 11, 2025
c785713
preen
bdraco Oct 11, 2025
5d50745
preen
bdraco Oct 11, 2025
720d3d5
cover raise case
bdraco Oct 11, 2025
2cca6ed
Update tests/conftest.py
bdraco Oct 11, 2025
3719848
Update tests/test_client_session.py
bdraco Oct 11, 2025
a134768
Update tests/test_client_session.py
bdraco Oct 11, 2025
34af199
Update tests/test_client_session.py
bdraco Oct 11, 2025
71d3a5b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 11, 2025
4efa317
add monkey
bdraco Oct 11, 2025
1a1e579
Update tests/test_client_functional.py
bdraco Oct 11, 2025
130aef4
Update tests/test_client_functional.py
bdraco Oct 11, 2025
9de96aa
Update tests/test_client_functional.py
bdraco Oct 11, 2025
ddf9d4d
Update tests/test_client_session.py
bdraco Oct 11, 2025
5858b5d
Update tests/test_client_session.py
bdraco Oct 11, 2025
22efeb9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 11, 2025
43463c5
preen
bdraco Oct 11, 2025
ca0f7cf
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 11, 2025
dad2c27
dry
bdraco Oct 11, 2025
cbf6e15
Merge remote-tracking branch 'upstream/netrc_blockingio_fix' into net…
bdraco Oct 11, 2025
a66abde
preen
bdraco Oct 11, 2025
d274ff7
lint
bdraco Oct 11, 2025
db59ba7
lint
bdraco Oct 11, 2025
f629a11
Update aiohttp/client.py
bdraco Oct 14, 2025
ecba61b
Merge branch 'master' into netrc_blockingio_fix
bdraco Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/10435.bugfix.rst
1 change: 1 addition & 0 deletions CHANGES/11634.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed blocking I/O in the event loop when using netrc authentication by moving netrc file lookup to an executor -- by :user:`bdraco`.
28 changes: 28 additions & 0 deletions aiohttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@
EMPTY_BODY_METHODS,
BasicAuth,
TimeoutHandle,
basicauth_from_netrc,
frozen_dataclass_decorator,
get_env_proxy_for_url,
netrc_from_env,
sentinel,
strip_auth_from_url,
)
Expand Down Expand Up @@ -586,6 +588,20 @@ async def _request(
)
):
auth = self._default_auth

# Try netrc if auth is still None and trust_env is enabled.
# Only check if NETRC environment variable is set to avoid
# creating an expensive executor job unnecessarily.
if (
auth is None
and self._trust_env
and url.host is not None
and os.environ.get("NETRC")
):
auth = await self._loop.run_in_executor(
None, self._get_netrc_auth, url.host
)

# It would be confusing if we support explicit
# Authorization header with auth argument
if auth is not None and hdrs.AUTHORIZATION in headers:
Expand Down Expand Up @@ -1131,6 +1147,18 @@ def _prepare_headers(self, headers: LooseHeaders | None) -> "CIMultiDict[str]":
added_names.add(key)
return result

def _get_netrc_auth(self, host: str) -> BasicAuth | None:
"""
Get auth from netrc for the given host.

This method is designed to be called in an executor to avoid
blocking I/O in the event loop.
"""
netrc_obj = netrc_from_env()
with suppress(LookupError):
return basicauth_from_netrc(netrc_obj, host)
return None

if sys.version_info >= (3, 11) and TYPE_CHECKING:

def get(
Expand Down
6 changes: 0 additions & 6 deletions aiohttp/client_reqrep.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@
BasicAuth,
HeadersMixin,
TimerNoop,
basicauth_from_netrc,
frozen_dataclass_decorator,
is_expected_content_type,
netrc_from_env,
parse_mimetype,
reify,
sentinel,
Expand Down Expand Up @@ -1068,10 +1066,6 @@ def update_auth(self, auth: BasicAuth | None, trust_env: bool = False) -> None:
"""Set basic auth."""
if auth is None:
auth = self.auth
if auth is None and trust_env and self.url.host is not None:
netrc_obj = netrc_from_env()
with contextlib.suppress(LookupError):
auth = basicauth_from_netrc(netrc_obj, self.url.host)
if auth is None:
return

Expand Down
32 changes: 28 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]:
with blockbuster_ctx(
"aiohttp", excluded_modules=["aiohttp.pytest_plugin", "aiohttp.test_utils"]
) as bb:
# TODO: Fix blocking call in ClientRequest's constructor.
# https://github.com/aio-libs/aiohttp/issues/10435
for func in ["io.TextIOWrapper.read", "os.stat"]:
bb.functions[func].can_block_in("aiohttp/client_reqrep.py", "update_auth")
for func in [
"os.getcwd",
"os.readlink",
Expand Down Expand Up @@ -292,6 +288,34 @@ def netrc_contents(
return netrc_file_path


@pytest.fixture
def netrc_default_contents(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Create a temporary netrc file with default test credentials and set NETRC env var."""
netrc_file = tmp_path / ".netrc"
netrc_file.write_text("default login netrc_user password netrc_pass\n")

monkeypatch.setenv("NETRC", str(netrc_file))

return netrc_file


@pytest.fixture
def no_netrc(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure NETRC environment variable is not set."""
monkeypatch.delenv("NETRC", raising=False)


@pytest.fixture
def netrc_other_host(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Create a temporary netrc file with credentials for a different host and set NETRC env var."""
netrc_file = tmp_path / ".netrc"
netrc_file.write_text("machine other.example.com login user password pass\n")

monkeypatch.setenv("NETRC", str(netrc_file))

return netrc_file


@pytest.fixture
def start_connection() -> Iterator[mock.Mock]:
with mock.patch(
Expand Down
63 changes: 63 additions & 0 deletions tests/test_client_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -3746,6 +3746,69 @@ async def handler(request: web.Request) -> NoReturn:
await client.get("/", headers=headers)


@pytest.mark.usefixtures("netrc_default_contents")
async def test_netrc_auth_from_env(aiohttp_client: AiohttpClient) -> None:
"""Test that netrc authentication works when NETRC env var is set and trust_env=True."""

async def handler(request: web.Request) -> web.Response:
return web.json_response({"headers": dict(request.headers)})

app = web.Application()
app.router.add_get("/", handler)

# Create client with trust_env=True
client = await aiohttp_client(app, trust_env=True)
async with client.get("/") as r:
assert r.status == 200
content = await r.json()
# Base64 encoded "netrc_user:netrc_pass" is "bmV0cmNfdXNlcjpuZXRyY19wYXNz"
assert content["headers"]["Authorization"] == "Basic bmV0cmNfdXNlcjpuZXRyY19wYXNz"


@pytest.mark.usefixtures('no_netrc')
async def test_netrc_auth_skipped_without_env_var(aiohttp_client: AiohttpClient) -> None:
"""Test that netrc authentication is skipped when NETRC env var is not set."""

async def handler(request: web.Request) -> web.Response:
return web.json_response({"headers": dict(request.headers)})

app = web.Application()
app.router.add_get("/", handler)

# Create client with trust_env=True but no NETRC env var
client = await aiohttp_client(app, trust_env=True)
async with client.get("/") as r:
assert r.status == 200
content = await r.json()
# No Authorization header should be present
assert "Authorization" not in content["headers"]


@pytest.mark.usefixtures('netrc_default_contents')
async def test_netrc_auth_overridden_by_explicit_auth(aiohttp_client: AiohttpClient) -> None:
"""Test that explicit auth parameter overrides netrc authentication."""

async def handler(request: web.Request) -> web.Response:
return web.json_response({"headers": dict(request.headers)})

app = web.Application()
app.router.add_get("/", handler)

# Create client with trust_env=True
client = await aiohttp_client(app, trust_env=True)
# Make request with explicit auth (should override netrc)
async with client.get(
"/", auth=aiohttp.BasicAuth("explicit_user", "explicit_pass")
) as r:
assert r.status == 200
content = await r.json()
# Base64 encoded "explicit_user:explicit_pass" is "ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
assert (
content["headers"]["Authorization"]
== "Basic ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
)


async def test_session_headers(aiohttp_client: AiohttpClient) -> None:
async def handler(request: web.Request) -> web.Response:
return web.json_response({"headers": dict(request.headers)})
Expand Down
22 changes: 1 addition & 21 deletions tests/test_client_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from yarl import URL

import aiohttp
from aiohttp import BaseConnector, hdrs, helpers, payload
from aiohttp import BaseConnector, hdrs, payload
from aiohttp.abc import AbstractStreamWriter
from aiohttp.base_protocol import BaseProtocol
from aiohttp.client_exceptions import ClientConnectionError
Expand Down Expand Up @@ -1574,26 +1574,6 @@ def test_gen_default_accept_encoding(
assert _gen_default_accept_encoding() == expected


@pytest.mark.parametrize(
("netrc_contents", "expected_auth"),
[
(
"machine example.com login username password pass\n",
helpers.BasicAuth("username", "pass"),
)
],
indirect=("netrc_contents",),
)
@pytest.mark.usefixtures("netrc_contents")
def test_basicauth_from_netrc_present( # type: ignore[misc]
make_request: _RequestMaker,
expected_auth: helpers.BasicAuth,
) -> None:
"""Test appropriate Authorization header is sent when netrc is not empty."""
req = make_request("get", "http://example.com", trust_env=True)
assert req.headers[hdrs.AUTHORIZATION] == expected_auth.encode()


@pytest.mark.parametrize(
"netrc_contents",
("machine example.com login username password pass\n",),
Expand Down
103 changes: 103 additions & 0 deletions tests/test_client_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import gc
import io
import json
import pathlib
import sys
import warnings
from collections import deque
Expand Down Expand Up @@ -89,6 +90,18 @@
)


def _make_auth_handler() -> Callable[[web.Request], Awaitable[web.Response]]:
"""Create a handler that returns auth header or 'no_auth'."""

async def handler(request: web.Request) -> web.Response:
auth_header = request.headers.get(hdrs.AUTHORIZATION)
if auth_header:
return web.Response(text=f"auth:{auth_header}")
return web.Response(text="no_auth")

return handler


async def test_close_coro(
create_session: Callable[..., Awaitable[ClientSession]],
) -> None:
Expand Down Expand Up @@ -1326,3 +1339,93 @@
value = uuid4()
setattr(session, inner_name, value)
assert value == getattr(session, outer_name)


@pytest.mark.usefixtures('netrc_default_contents')
async def test_netrc_auth_with_trust_env(aiohttp_server: AiohttpServer) -> None:
"""Test that netrc authentication works with ClientSession when NETRC env var is set."""
app = web.Application()
app.router.add_get("/", _make_auth_handler())

server = await aiohttp_server(app)
# Create session with trust_env=True to test ClientSession directly
async with (
ClientSession(trust_env=True) as session,
session.get(server.make_url("/")) as resp,
):
text = await resp.text()
# Base64 encoded "netrc_user:netrc_pass" is "bmV0cmNfdXNlcjpuZXRyY19wYXNz"
assert text == "auth:Basic bmV0cmNfdXNlcjpuZXRyY19wYXNz"


@pytest.mark.usefixtures("netrc_default_contents")
async def test_netrc_auth_skipped_without_trust_env(
aiohttp_server: AiohttpServer,
) -> None:
"""Test that netrc authentication is skipped when trust_env=False."""
app = web.Application()
app.router.add_get("/", _make_auth_handler())

server = await aiohttp_server(app)
# Create session with trust_env=False (default) to test ClientSession directly
async with (
ClientSession(trust_env=False) as session,
session.get(server.make_url("/")) as resp,
):
text = await resp.text()
assert text == "no_auth"


@pytest.mark.usefixtures('no_netrc')
async def test_netrc_auth_skipped_without_netrc_env(aiohttp_server: AiohttpServer) -> None:
"""Test that netrc authentication is skipped when NETRC env var is not set."""
app = web.Application()
app.router.add_get("/", _make_auth_handler())

server = await aiohttp_server(app)
# Create session with trust_env=True but no NETRC env var to test ClientSession directly
async with (
ClientSession(trust_env=True) as session,
session.get(server.make_url("/")) as resp,
):
text = await resp.text()
assert text == "no_auth"


@pytest.mark.usefixtures("netrc_default_contents")
async def test_netrc_auth_overridden_by_explicit_auth(
aiohttp_server: AiohttpServer,
) -> None:
"""Test that explicit auth parameter overrides netrc authentication."""
app = web.Application()
app.router.add_get("/", _make_auth_handler())

server = await aiohttp_server(app)
# Create session with trust_env=True to test ClientSession directly
async with (
ClientSession(trust_env=True) as session,
session.get(
server.make_url("/"),
auth=aiohttp.BasicAuth("explicit_user", "explicit_pass"),
) as resp,
):
text = await resp.text()
# Base64 encoded "explicit_user:explicit_pass" is "ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
assert text == "auth:Basic ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"


@pytest.mark.usefixtures("netrc_other_host")
async def test_netrc_auth_host_not_in_netrc(aiohttp_server: AiohttpServer) -> None:
"""Test that netrc lookup returns None when host is not in netrc file."""
app = web.Application()
app.router.add_get("/", _make_auth_handler())

server = await aiohttp_server(app)

async with (
ClientSession(trust_env=True) as session,
session.get(server.make_url("/")) as resp,
):
text = await resp.text()
# Should not have auth since the host is not in netrc
assert text == "no_auth"
Loading