Skip to content

Commit 886a3b0

Browse files
committed
Implement granular URL error hierarchy in the HTTP client aio-libs#6722
1 parent d00a32b commit 886a3b0

10 files changed

+332
-22
lines changed

CHANGES/2507.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
6722.feature

CHANGES/3315.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
6722.feature

CHANGES/6722.feature

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Added 5 new exceptions: :py:exc:`~aiohttp.InvalidUrlClientError`, :py:exc:`~aiohttp.RedirectClientError`,
2+
:py:exc:`~aiohttp.NonHttpUrlClientError`, :py:exc:`~aiohttp.InvalidUrlRedirectClientError`,
3+
:py:exc:`~aiohttp.NonHttpUrlRedirectClientError`
4+
5+
:py:exc:`~aiohttp.InvalidUrlRedirectClientError`, :py:exc:`~aiohttp.NonHttpUrlRedirectClientError`
6+
are raised instead of :py:exc:`ValueError` or :py:exc:`~aiohttp.InvalidURL` when the redirect URL is invalid. Classes
7+
:py:exc:`~aiohttp.InvalidUrlClientError`, :py:exc:`~aiohttp.RedirectClientError`,
8+
:py:exc:`~aiohttp.NonHttpUrlClientError` are base for them.
9+
10+
The :py:exc:`~aiohttp.InvalidURL` now exposes a ``description`` property with the text explanation of the error details.
11+
12+
-- by :user:`setla`

CONTRIBUTORS.txt

+1
Original file line numberDiff line numberDiff line change
@@ -365,5 +365,6 @@ Yuvi Panda
365365
Zainab Lawal
366366
Zeal Wierslee
367367
Zlatan Sičanica
368+
Łukasz Setla
368369
Марк Коренберг
369370
Семён Марьясин

aiohttp/__init__.py

+10
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@
2424
ContentTypeError as ContentTypeError,
2525
Fingerprint as Fingerprint,
2626
InvalidURL as InvalidURL,
27+
InvalidUrlClientError as InvalidUrlClientError,
28+
InvalidUrlRedirectClientError as InvalidUrlRedirectClientError,
2729
NamedPipeConnector as NamedPipeConnector,
30+
NonHttpUrlClientError as NonHttpUrlClientError,
31+
NonHttpUrlRedirectClientError as NonHttpUrlRedirectClientError,
32+
RedirectClientError as RedirectClientError,
2833
RequestInfo as RequestInfo,
2934
ServerConnectionError as ServerConnectionError,
3035
ServerDisconnectedError as ServerDisconnectedError,
@@ -134,6 +139,11 @@
134139
"ContentTypeError",
135140
"Fingerprint",
136141
"InvalidURL",
142+
"InvalidUrlClientError",
143+
"InvalidUrlRedirectClientError",
144+
"NonHttpUrlClientError",
145+
"NonHttpUrlRedirectClientError",
146+
"RedirectClientError",
137147
"RequestInfo",
138148
"ServerConnectionError",
139149
"ServerDisconnectedError",

aiohttp/client.py

+55-10
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
ClientSSLError as ClientSSLError,
5252
ContentTypeError as ContentTypeError,
5353
InvalidURL as InvalidURL,
54+
InvalidUrlClientError as InvalidUrlClientError,
55+
InvalidUrlRedirectClientError as InvalidUrlRedirectClientError,
56+
NonHttpUrlClientError as NonHttpUrlClientError,
57+
NonHttpUrlRedirectClientError as NonHttpUrlRedirectClientError,
58+
RedirectClientError as RedirectClientError,
5459
ServerConnectionError as ServerConnectionError,
5560
ServerDisconnectedError as ServerDisconnectedError,
5661
ServerFingerprintMismatch as ServerFingerprintMismatch,
@@ -106,6 +111,11 @@
106111
"ClientSSLError",
107112
"ContentTypeError",
108113
"InvalidURL",
114+
"InvalidUrlClientError",
115+
"RedirectClientError",
116+
"NonHttpUrlClientError",
117+
"InvalidUrlRedirectClientError",
118+
"NonHttpUrlRedirectClientError",
109119
"ServerConnectionError",
110120
"ServerDisconnectedError",
111121
"ServerFingerprintMismatch",
@@ -162,6 +172,10 @@ class ClientTimeout:
162172
# 5 Minute default read timeout
163173
DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60)
164174

175+
# https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
176+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"})
177+
HTTP_SCHEMA_SET = frozenset({"http", "https", ""})
178+
165179
_RetType = TypeVar("_RetType")
166180
_CharsetResolver = Callable[[ClientResponse, bytes], str]
167181

@@ -448,7 +462,10 @@ async def _request(
448462
try:
449463
url = self._build_url(str_or_url)
450464
except ValueError as e:
451-
raise InvalidURL(str_or_url) from e
465+
raise InvalidUrlClientError(str_or_url) from e
466+
467+
if url.scheme not in HTTP_SCHEMA_SET:
468+
raise NonHttpUrlClientError(url)
452469

453470
skip_headers = set(self._skip_auto_headers)
454471
if skip_auto_headers is not None:
@@ -504,6 +521,15 @@ async def _request(
504521
with timer:
505522
while True:
506523
url, auth_from_url = strip_auth_from_url(url)
524+
if not url.raw_host:
525+
# NOTE: Bail early, otherwise, causes `InvalidURL` through
526+
# NOTE: `self._request_class()` below.
527+
err_exc_cls = (
528+
InvalidUrlRedirectClientError
529+
if redirects
530+
else InvalidUrlClientError
531+
)
532+
raise err_exc_cls(url)
507533
if auth and auth_from_url:
508534
raise ValueError(
509535
"Cannot combine AUTH argument with "
@@ -656,25 +682,44 @@ async def _request(
656682
resp.release()
657683

658684
try:
659-
parsed_url = URL(
685+
parsed_redirect_url = URL(
660686
r_url, encoded=not self._requote_redirect_url
661687
)
662-
663688
except ValueError as e:
664-
raise InvalidURL(r_url) from e
689+
raise InvalidUrlRedirectClientError(
690+
r_url,
691+
"Server attempted redirecting to a location that does not look like a URL",
692+
) from e
665693

666-
scheme = parsed_url.scheme
667-
if scheme not in ("http", "https", ""):
694+
scheme = parsed_redirect_url.scheme
695+
if scheme not in HTTP_SCHEMA_SET:
668696
resp.close()
669-
raise ValueError("Can redirect only to http or https")
697+
raise NonHttpUrlRedirectClientError(r_url)
670698
elif not scheme:
671-
parsed_url = url.join(parsed_url)
699+
parsed_redirect_url = url.join(parsed_redirect_url)
672700

673-
if url.origin() != parsed_url.origin():
701+
is_same_host_https_redirect = (
702+
url.host == parsed_redirect_url.host
703+
and parsed_redirect_url.scheme == "https"
704+
and url.scheme == "http"
705+
)
706+
707+
try:
708+
redirect_origin = parsed_redirect_url.origin()
709+
except ValueError as origin_val_err:
710+
raise InvalidUrlRedirectClientError(
711+
parsed_redirect_url,
712+
"Invalid redirect URL origin",
713+
) from origin_val_err
714+
715+
if (
716+
url.origin() != redirect_origin
717+
and not is_same_host_https_redirect
718+
):
674719
auth = None
675720
headers.pop(hdrs.AUTHORIZATION, None)
676721

677-
url = parsed_url
722+
url = parsed_redirect_url
678723
params = {}
679724
resp.release()
680725
continue

aiohttp/client_exceptions.py

+47-7
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import asyncio
44
import warnings
5-
from typing import TYPE_CHECKING, Any, Optional, Tuple, Union
5+
from typing import TYPE_CHECKING, Optional, Tuple, Union
66

77
from .http_parser import RawResponseMessage
8-
from .typedefs import LooseHeaders
8+
from .typedefs import LooseHeaders, StrOrURL
99

1010
try:
1111
import ssl
@@ -39,6 +39,11 @@
3939
"ContentTypeError",
4040
"ClientPayloadError",
4141
"InvalidURL",
42+
"InvalidUrlClientError",
43+
"RedirectClientError",
44+
"NonHttpUrlClientError",
45+
"InvalidUrlRedirectClientError",
46+
"NonHttpUrlRedirectClientError",
4247
)
4348

4449

@@ -271,17 +276,52 @@ class InvalidURL(ClientError, ValueError):
271276

272277
# Derive from ValueError for backward compatibility
273278

274-
def __init__(self, url: Any) -> None:
279+
def __init__(self, url: StrOrURL, description: Union[str, None] = None) -> None:
275280
# The type of url is not yarl.URL because the exception can be raised
276281
# on URL(url) call
277-
super().__init__(url)
282+
self._url = url
283+
self._description = description
284+
285+
if description:
286+
super().__init__(url, description)
287+
else:
288+
super().__init__(url)
289+
290+
@property
291+
def url(self) -> StrOrURL:
292+
return self._url
278293

279294
@property
280-
def url(self) -> Any:
281-
return self.args[0]
295+
def description(self) -> "str | None":
296+
return self._description
282297

283298
def __repr__(self) -> str:
284-
return f"<{self.__class__.__name__} {self.url}>"
299+
return f"<{self.__class__.__name__} {self}>"
300+
301+
def __str__(self) -> str:
302+
if self._description:
303+
return f"{self._url} - {self._description}"
304+
return str(self._url)
305+
306+
307+
class InvalidUrlClientError(InvalidURL):
308+
"""Invalid URL client error."""
309+
310+
311+
class RedirectClientError(ClientError):
312+
"""Client redirect error."""
313+
314+
315+
class NonHttpUrlClientError(ClientError):
316+
"""Non http URL client error."""
317+
318+
319+
class InvalidUrlRedirectClientError(InvalidUrlClientError, RedirectClientError):
320+
"""Invalid URL redirect client error."""
321+
322+
323+
class NonHttpUrlRedirectClientError(NonHttpUrlClientError, RedirectClientError):
324+
"""Non http URL redirect client error."""
285325

286326

287327
class ClientSSLError(ClientConnectorError):

docs/client_reference.rst

+49
Original file line numberDiff line numberDiff line change
@@ -2096,6 +2096,41 @@ All exceptions are available as members of *aiohttp* module.
20962096

20972097
Invalid URL, :class:`yarl.URL` instance.
20982098

2099+
.. attribute:: description
2100+
2101+
Invalid URL description, :class:`str` instance or :data:`None`.
2102+
2103+
.. exception:: InvalidUrlClientError
2104+
2105+
Base class for all errors related to client url.
2106+
2107+
Derived from :exc:`InvalidURL`
2108+
2109+
.. exception:: RedirectClientError
2110+
2111+
Base class for all errors related to client redirects.
2112+
2113+
Derived from :exc:`ClientError`
2114+
2115+
.. exception:: NonHttpUrlClientError
2116+
2117+
Base class for all errors related to non http client urls.
2118+
2119+
Derived from :exc:`ClientError`
2120+
2121+
.. exception:: InvalidUrlRedirectClientError
2122+
2123+
Redirect URL is malformed, e.g. it does not contain host part.
2124+
2125+
Derived from :exc:`InvalidUrlClientError` and :exc:`RedirectClientError`
2126+
2127+
.. exception:: NonHttpUrlRedirectClientError
2128+
2129+
Redirect URL does not contain http schema.
2130+
2131+
Derived from :exc:`RedirectClientError` and :exc:`NonHttpUrlClientError`
2132+
2133+
20992134
.. class:: ContentDisposition
21002135

21012136
Represent Content-Disposition header
@@ -2297,3 +2332,17 @@ Hierarchy of exceptions
22972332
* :exc:`WSServerHandshakeError`
22982333

22992334
* :exc:`InvalidURL`
2335+
2336+
* :exc:`InvalidUrlClientError`
2337+
2338+
* :exc:`InvalidUrlRedirectClientError`
2339+
2340+
* :exc:`NonHttpUrlClientError`
2341+
2342+
* :exc:`NonHttpUrlRedirectClientError`
2343+
2344+
* :exc:`RedirectClientError`
2345+
2346+
* :exc:`InvalidUrlRedirectClientError`
2347+
2348+
* :exc:`NonHttpUrlRedirectClientError`

tests/test_client_exceptions.py

+22-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest import mock
66

77
import pytest
8+
from yarl import URL
89

910
from aiohttp import client, client_reqrep
1011

@@ -298,8 +299,9 @@ def test_repr(self) -> None:
298299

299300
class TestInvalidURL:
300301
def test_ctor(self) -> None:
301-
err = client.InvalidURL(url=":wrong:url:")
302+
err = client.InvalidURL(url=":wrong:url:", description=":description:")
302303
assert err.url == ":wrong:url:"
304+
assert err.description == ":description:"
303305

304306
def test_pickle(self) -> None:
305307
err = client.InvalidURL(url=":wrong:url:")
@@ -310,10 +312,27 @@ def test_pickle(self) -> None:
310312
assert err2.url == ":wrong:url:"
311313
assert err2.foo == "bar"
312314

313-
def test_repr(self) -> None:
315+
def test_repr_no_description(self) -> None:
314316
err = client.InvalidURL(url=":wrong:url:")
317+
assert err.args == (":wrong:url:",)
315318
assert repr(err) == "<InvalidURL :wrong:url:>"
316319

317-
def test_str(self) -> None:
320+
def test_repr_yarl_URL(self) -> None:
321+
err = client.InvalidURL(url=URL(":wrong:url:"))
322+
assert repr(err) == "<InvalidURL :wrong:url:>"
323+
324+
def test_repr_with_description(self) -> None:
325+
err = client.InvalidURL(url=":wrong:url:", description=":description:")
326+
assert repr(err) == "<InvalidURL :wrong:url: - :description:>"
327+
328+
def test_str_no_description(self) -> None:
318329
err = client.InvalidURL(url=":wrong:url:")
319330
assert str(err) == ":wrong:url:"
331+
332+
def test_none_description(self) -> None:
333+
err = client.InvalidURL(":wrong:url:")
334+
assert err.description is None
335+
336+
def test_str_with_description(self) -> None:
337+
err = client.InvalidURL(url=":wrong:url:", description=":description:")
338+
assert str(err) == ":wrong:url: - :description:"

0 commit comments

Comments
 (0)