Skip to content

Commit fb465e1

Browse files
setlawebknjaz
andauthored
Implement granular URL error hierarchy in the HTTP client
This patch introduces 5 granular user-facing exceptions that may occur when HTTP requests are made: * `InvalidUrlClientError` * `RedirectClientError` * `NonHttpUrlClientError` * `InvalidUrlRedirectClientError` * `NonHttpUrlRedirectClientError` Previously `ValueError` or `InvalidURL` was raised and screening out was complicated (a valid URL that redirects to invalid one raised the same error as an invalid URL). Ref: #6722 (comment) PR #6722 Resolves #2507 Resolves #2630 Resolves #3315 Co-authored-by: Sviatoslav Sydorenko <[email protected]>
1 parent 0ec65c0 commit fb465e1

File tree

10 files changed

+324
-24
lines changed

10 files changed

+324
-24
lines changed

CHANGES/2507.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
6722.feature

CHANGES/3315.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
6722.feature

CHANGES/6722.feature

Lines changed: 12 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,5 +375,6 @@ Yuvi Panda
375375
Zainab Lawal
376376
Zeal Wierslee
377377
Zlatan Sičanica
378+
Łukasz Setla
378379
Марк Коренберг
379380
Семён Марьясин

aiohttp/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
ContentTypeError,
2626
Fingerprint,
2727
InvalidURL,
28+
InvalidUrlClientError,
29+
InvalidUrlRedirectClientError,
2830
NamedPipeConnector,
31+
NonHttpUrlClientError,
32+
NonHttpUrlRedirectClientError,
33+
RedirectClientError,
2934
RequestInfo,
3035
ServerConnectionError,
3136
ServerDisconnectedError,
@@ -129,6 +134,11 @@
129134
"ContentTypeError",
130135
"Fingerprint",
131136
"InvalidURL",
137+
"InvalidUrlClientError",
138+
"InvalidUrlRedirectClientError",
139+
"NonHttpUrlClientError",
140+
"NonHttpUrlRedirectClientError",
141+
"RedirectClientError",
132142
"RequestInfo",
133143
"ServerConnectionError",
134144
"ServerDisconnectedError",

aiohttp/client.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@
5454
ConnectionTimeoutError,
5555
ContentTypeError,
5656
InvalidURL,
57+
InvalidUrlClientError,
58+
InvalidUrlRedirectClientError,
59+
NonHttpUrlClientError,
60+
NonHttpUrlRedirectClientError,
61+
RedirectClientError,
5762
ServerConnectionError,
5863
ServerDisconnectedError,
5964
ServerFingerprintMismatch,
@@ -108,6 +113,11 @@
108113
"ConnectionTimeoutError",
109114
"ContentTypeError",
110115
"InvalidURL",
116+
"InvalidUrlClientError",
117+
"RedirectClientError",
118+
"NonHttpUrlClientError",
119+
"InvalidUrlRedirectClientError",
120+
"NonHttpUrlRedirectClientError",
111121
"ServerConnectionError",
112122
"ServerDisconnectedError",
113123
"ServerFingerprintMismatch",
@@ -167,6 +177,7 @@ class ClientTimeout:
167177

168178
# https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
169179
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"})
180+
HTTP_SCHEMA_SET = frozenset({"http", "https", ""})
170181

171182
_RetType = TypeVar("_RetType")
172183
_CharsetResolver = Callable[[ClientResponse, bytes], str]
@@ -404,7 +415,10 @@ async def _request(
404415
try:
405416
url = self._build_url(str_or_url)
406417
except ValueError as e:
407-
raise InvalidURL(str_or_url) from e
418+
raise InvalidUrlClientError(str_or_url) from e
419+
420+
if url.scheme not in HTTP_SCHEMA_SET:
421+
raise NonHttpUrlClientError(url)
408422

409423
skip_headers = set(self._skip_auto_headers)
410424
if skip_auto_headers is not None:
@@ -459,6 +473,15 @@ async def _request(
459473
retry_persistent_connection = method in IDEMPOTENT_METHODS
460474
while True:
461475
url, auth_from_url = strip_auth_from_url(url)
476+
if not url.raw_host:
477+
# NOTE: Bail early, otherwise, causes `InvalidURL` through
478+
# NOTE: `self._request_class()` below.
479+
err_exc_cls = (
480+
InvalidUrlRedirectClientError
481+
if redirects
482+
else InvalidUrlClientError
483+
)
484+
raise err_exc_cls(url)
462485
if auth and auth_from_url:
463486
raise ValueError(
464487
"Cannot combine AUTH argument with "
@@ -611,34 +634,44 @@ async def _request(
611634
resp.release()
612635

613636
try:
614-
parsed_url = URL(
637+
parsed_redirect_url = URL(
615638
r_url, encoded=not self._requote_redirect_url
616639
)
617-
618640
except ValueError as e:
619-
raise InvalidURL(r_url) from e
641+
raise InvalidUrlRedirectClientError(
642+
r_url,
643+
"Server attempted redirecting to a location that does not look like a URL",
644+
) from e
620645

621-
scheme = parsed_url.scheme
622-
if scheme not in ("http", "https", ""):
646+
scheme = parsed_redirect_url.scheme
647+
if scheme not in HTTP_SCHEMA_SET:
623648
resp.close()
624-
raise ValueError("Can redirect only to http or https")
649+
raise NonHttpUrlRedirectClientError(r_url)
625650
elif not scheme:
626-
parsed_url = url.join(parsed_url)
651+
parsed_redirect_url = url.join(parsed_redirect_url)
627652

628653
is_same_host_https_redirect = (
629-
url.host == parsed_url.host
630-
and parsed_url.scheme == "https"
654+
url.host == parsed_redirect_url.host
655+
and parsed_redirect_url.scheme == "https"
631656
and url.scheme == "http"
632657
)
633658

659+
try:
660+
redirect_origin = parsed_redirect_url.origin()
661+
except ValueError as origin_val_err:
662+
raise InvalidUrlRedirectClientError(
663+
parsed_redirect_url,
664+
"Invalid redirect URL origin",
665+
) from origin_val_err
666+
634667
if (
635-
url.origin() != parsed_url.origin()
668+
url.origin() != redirect_origin
636669
and not is_same_host_https_redirect
637670
):
638671
auth = None
639672
headers.pop(hdrs.AUTHORIZATION, None)
640673

641-
url = parsed_url
674+
url = parsed_redirect_url
642675
params = {}
643676
resp.release()
644677
continue

aiohttp/client_exceptions.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""HTTP related errors."""
22

33
import asyncio
4-
from typing import TYPE_CHECKING, Any, Optional, Tuple, Union
4+
from typing import TYPE_CHECKING, Optional, Tuple, Union
55

66
from .http_parser import RawResponseMessage
7-
from .typedefs import LooseHeaders
7+
from .typedefs import LooseHeaders, StrOrURL
88

99
try:
1010
import ssl
@@ -40,6 +40,11 @@
4040
"ContentTypeError",
4141
"ClientPayloadError",
4242
"InvalidURL",
43+
"InvalidUrlClientError",
44+
"RedirectClientError",
45+
"NonHttpUrlClientError",
46+
"InvalidUrlRedirectClientError",
47+
"NonHttpUrlRedirectClientError",
4348
)
4449

4550

@@ -248,17 +253,52 @@ class InvalidURL(ClientError, ValueError):
248253

249254
# Derive from ValueError for backward compatibility
250255

251-
def __init__(self, url: Any) -> None:
256+
def __init__(self, url: StrOrURL, description: Union[str, None] = None) -> None:
252257
# The type of url is not yarl.URL because the exception can be raised
253258
# on URL(url) call
254-
super().__init__(url)
259+
self._url = url
260+
self._description = description
261+
262+
if description:
263+
super().__init__(url, description)
264+
else:
265+
super().__init__(url)
266+
267+
@property
268+
def url(self) -> StrOrURL:
269+
return self._url
255270

256271
@property
257-
def url(self) -> Any:
258-
return self.args[0]
272+
def description(self) -> "str | None":
273+
return self._description
259274

260275
def __repr__(self) -> str:
261-
return f"<{self.__class__.__name__} {self.url}>"
276+
return f"<{self.__class__.__name__} {self}>"
277+
278+
def __str__(self) -> str:
279+
if self._description:
280+
return f"{self._url} - {self._description}"
281+
return str(self._url)
282+
283+
284+
class InvalidUrlClientError(InvalidURL):
285+
"""Invalid URL client error."""
286+
287+
288+
class RedirectClientError(ClientError):
289+
"""Client redirect error."""
290+
291+
292+
class NonHttpUrlClientError(ClientError):
293+
"""Non http URL client error."""
294+
295+
296+
class InvalidUrlRedirectClientError(InvalidUrlClientError, RedirectClientError):
297+
"""Invalid URL redirect client error."""
298+
299+
300+
class NonHttpUrlRedirectClientError(NonHttpUrlClientError, RedirectClientError):
301+
"""Non http URL redirect client error."""
262302

263303

264304
class ClientSSLError(ClientConnectorError):

docs/client_reference.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,6 +2099,41 @@ All exceptions are available as members of *aiohttp* module.
20992099

21002100
Invalid URL, :class:`yarl.URL` instance.
21012101

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

21042139
Represent Content-Disposition header
@@ -2315,3 +2350,17 @@ Hierarchy of exceptions
23152350
* :exc:`WSServerHandshakeError`
23162351

23172352
* :exc:`InvalidURL`
2353+
2354+
* :exc:`InvalidUrlClientError`
2355+
2356+
* :exc:`InvalidUrlRedirectClientError`
2357+
2358+
* :exc:`NonHttpUrlClientError`
2359+
2360+
* :exc:`NonHttpUrlRedirectClientError`
2361+
2362+
* :exc:`RedirectClientError`
2363+
2364+
* :exc:`InvalidUrlRedirectClientError`
2365+
2366+
* :exc:`NonHttpUrlRedirectClientError`

tests/test_client_exceptions.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import pickle
66
from typing import Any
77

8+
from yarl import URL
9+
810
from aiohttp import client, client_reqrep
911

1012

@@ -268,8 +270,9 @@ def test_repr(self) -> None:
268270

269271
class TestInvalidURL:
270272
def test_ctor(self) -> None:
271-
err = client.InvalidURL(url=":wrong:url:")
273+
err = client.InvalidURL(url=":wrong:url:", description=":description:")
272274
assert err.url == ":wrong:url:"
275+
assert err.description == ":description:"
273276

274277
def test_pickle(self) -> None:
275278
err = client.InvalidURL(url=":wrong:url:")
@@ -280,10 +283,27 @@ def test_pickle(self) -> None:
280283
assert err2.url == ":wrong:url:"
281284
assert err2.foo == "bar"
282285

283-
def test_repr(self) -> None:
286+
def test_repr_no_description(self) -> None:
284287
err = client.InvalidURL(url=":wrong:url:")
288+
assert err.args == (":wrong:url:",)
285289
assert repr(err) == "<InvalidURL :wrong:url:>"
286290

287-
def test_str(self) -> None:
291+
def test_repr_yarl_URL(self) -> None:
292+
err = client.InvalidURL(url=URL(":wrong:url:"))
293+
assert repr(err) == "<InvalidURL :wrong:url:>"
294+
295+
def test_repr_with_description(self) -> None:
296+
err = client.InvalidURL(url=":wrong:url:", description=":description:")
297+
assert repr(err) == "<InvalidURL :wrong:url: - :description:>"
298+
299+
def test_str_no_description(self) -> None:
288300
err = client.InvalidURL(url=":wrong:url:")
289301
assert str(err) == ":wrong:url:"
302+
303+
def test_none_description(self) -> None:
304+
err = client.InvalidURL(":wrong:url:")
305+
assert err.description is None
306+
307+
def test_str_with_description(self) -> None:
308+
err = client.InvalidURL(url=":wrong:url:", description=":description:")
309+
assert str(err) == ":wrong:url: - :description:"

0 commit comments

Comments
 (0)