Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement granular URL error hierarchy in the HTTP client #6722

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
b0474ab
Create new exception relevant for invalid redirect url, update tests
setla Apr 27, 2022
8c132cc
Use InvalidRedirectUrl exception when redirect url is invalid
setla Apr 27, 2022
8217849
update docs
setla Apr 27, 2022
56f2408
Update wrong return type
setla Apr 27, 2022
416b1d6
Add myself to contributors list
setla Apr 27, 2022
3782f88
Improve client test coverage
setla Apr 27, 2022
c6a32db
Add changes file
setla Apr 27, 2022
a6babca
Use parametrize in tests, few more invalid url test cases
setla Apr 28, 2022
2819150
Remove body from response
setla Apr 28, 2022
3308058
fix InvalidURL description, more direct init approach
setla Apr 28, 2022
7f37635
Ignore Any returns, update types
setla Apr 28, 2022
4f7c7b2
Update url type to StrOrURL.
setla Apr 29, 2022
b8ff8af
Rename InvalidRedirectUrl to InvalidRedirectURL
setla May 24, 2022
05ffdb0
Map InvalidURL args to str (yarl.URL needs it)
setla May 31, 2022
11f20e9
Resolve conflicts
setla Jan 30, 2024
fe39f22
Update CHANGES/6722.feature
setla Jan 30, 2024
70921c5
Use Union instead of Optional
setla Jan 30, 2024
aa7a63c
Check if description is None
setla Jan 30, 2024
ff3100a
Update InvalidRedirectUrl docs
setla Jan 30, 2024
23d2e9f
Update InvalidRedirectURL docsting
setla Jan 30, 2024
b0a68cc
Update test_invalid_redirect_url
setla Jan 30, 2024
1612c92
Add dot in InvalidRedirectURL docstring
setla Jan 30, 2024
a39e190
fix test_repr_no_description
setla Jan 30, 2024
6a4cf9f
fix docs typo
setla Jan 30, 2024
6357cf2
Link exceptions in the change note
webknjaz Jan 30, 2024
449cb33
Add 6722.feature symlink
setla Jan 31, 2024
7236ad1
Update aiohttp/client_exceptions.py - remove Optional
setla Jan 31, 2024
5cdbab7
Add test for None description
setla Jan 31, 2024
c0334c4
Update docs/client_reference.rst
setla Jan 31, 2024
6011ced
Update docs/client_reference.rst
setla Jan 31, 2024
9e5de99
Set InvalidURL init values as self params
setla Jan 31, 2024
d94cdff
Use new InvalidURL self args
setla Jan 31, 2024
8e824fd
add description to InvalidRedirectURL raised from ValueError
setla Jan 31, 2024
9c1c159
Add description to InvalidRedirectURL raised instead of InvalidURL
setla Jan 31, 2024
ebd3c05
Use explicite self arguments instead of self.args
setla Jan 31, 2024
f96b76a
fix mypy
setla Jan 31, 2024
2fa0fa1
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Jan 31, 2024
090fe74
fix mypy
setla Jan 31, 2024
c10075d
Add 3315.feature.rst sumlink
setla Feb 1, 2024
05aa519
Add test exception message match pattern
setla Feb 1, 2024
ed13789
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Feb 2, 2024
9733022
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Feb 2, 2024
67ab859
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Feb 3, 2024
d10d3d3
use r-string
setla Feb 4, 2024
37871a0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 4, 2024
1379128
New exceptions hierarchy
setla Feb 4, 2024
c27bca3
fix spell check
setla Feb 4, 2024
9598332
fix test_HTTP_302_REDIRECT_NON_HTTP
setla Feb 4, 2024
537bb82
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Feb 6, 2024
9c859a4
Update test parametrize declaration
setla Feb 9, 2024
a910301
Update tests/test_client_functional.py
setla Feb 9, 2024
47a0043
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Feb 9, 2024
0c3a1a3
restore test + improve NonHttpUrlRedirectClientError
setla Feb 9, 2024
56b4781
update docs
setla Feb 9, 2024
f815b94
Add add error for non-redirect urls with no schema, new tests
setla Feb 9, 2024
d1e26d5
extract https schemas into variable
setla Feb 9, 2024
91c14a1
Merge branch 'master' into new-exception-when-redirect-url-is-invalid
setla Feb 10, 2024
5f17628
Make the change note byline standalone
webknjaz Feb 12, 2024
98cd194
Use specific `InvalidUrlClientError` @ initial client URL validation
webknjaz Feb 12, 2024
5af3f74
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 12, 2024
89f9757
Fix the fictional bsky URL example in tests
webknjaz Feb 12, 2024
86645df
Make the phone number test case realistic
webknjaz Feb 12, 2024
d8e1d26
Change `test_invalid_and_non_http_url` to use async CM
webknjaz Feb 12, 2024
7a87251
Hotfix raise indent
webknjaz Feb 12, 2024
cb00d40
Import `InvalidUrlClientError` @ tests
webknjaz Feb 12, 2024
05038d8
add new exceptions to __init__.py
setla Feb 12, 2024
eda791d
fix tests
setla Feb 12, 2024
c5e5b9e
Expect specific `InvalidUrlRedirectClientError` @ tests
webknjaz Feb 13, 2024
2022cde
Raise an invalid URL error on missing host
webknjaz Feb 13, 2024
a04e61e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 13, 2024
7c9194e
fixup! test
webknjaz Feb 13, 2024
80b5479
fixup! Link exceptions @ doc
webknjaz Feb 13, 2024
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/2507.feature.rst
1 change: 1 addition & 0 deletions CHANGES/3315.feature.rst
12 changes: 12 additions & 0 deletions CHANGES/6722.feature
setla marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Added 5 new exceptions: :py:exc:`~aiohttp.InvalidUrlClientError`, :py:exc:`~aiohttp.RedirectClientError`,
:py:exc:`~aiohttp.NonHttpUrlClientError`, :py:exc:`~aiohttp.InvalidUrlRedirectClientError`,
:py:exc:`~aiohttp.NonHttpUrlRedirectClientError`

:py:exc:`~aiohttp.InvalidUrlRedirectClientError`, :py:exc:`~aiohttp.NonHttpUrlRedirectClientError`
are raised instead of :py:exc:`ValueError` or :py:exc:`~aiohttp.InvalidURL` when the redirect URL is invalid. Classes
:py:exc:`~aiohttp.InvalidUrlClientError`, :py:exc:`~aiohttp.RedirectClientError`,
:py:exc:`~aiohttp.NonHttpUrlClientError` are base for them.

The :py:exc:`~aiohttp.InvalidURL` now exposes a ``description`` property with the text explanation of the error details.

-- by :user:`setla`
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -375,5 +375,6 @@ Yuvi Panda
Zainab Lawal
Zeal Wierslee
Zlatan Sičanica
Łukasz Setla
Марк Коренберг
Семён Марьясин
10 changes: 10 additions & 0 deletions aiohttp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
ContentTypeError,
Fingerprint,
InvalidURL,
InvalidUrlClientError,
InvalidUrlRedirectClientError,
setla marked this conversation as resolved.
Show resolved Hide resolved
NamedPipeConnector,
NonHttpUrlClientError,
NonHttpUrlRedirectClientError,
RedirectClientError,
RequestInfo,
ServerConnectionError,
ServerDisconnectedError,
Expand Down Expand Up @@ -129,6 +134,11 @@
"ContentTypeError",
"Fingerprint",
"InvalidURL",
"InvalidUrlClientError",
"InvalidUrlRedirectClientError",
"NonHttpUrlClientError",
"NonHttpUrlRedirectClientError",
"RedirectClientError",
"RequestInfo",
"ServerConnectionError",
"ServerDisconnectedError",
Expand Down
57 changes: 45 additions & 12 deletions aiohttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
ConnectionTimeoutError,
ContentTypeError,
InvalidURL,
InvalidUrlClientError,
InvalidUrlRedirectClientError,
NonHttpUrlClientError,
NonHttpUrlRedirectClientError,
RedirectClientError,
ServerConnectionError,
ServerDisconnectedError,
ServerFingerprintMismatch,
Expand Down Expand Up @@ -108,6 +113,11 @@
"ConnectionTimeoutError",
"ContentTypeError",
"InvalidURL",
"InvalidUrlClientError",
"RedirectClientError",
"NonHttpUrlClientError",
"InvalidUrlRedirectClientError",
"NonHttpUrlRedirectClientError",
"ServerConnectionError",
"ServerDisconnectedError",
"ServerFingerprintMismatch",
Expand Down Expand Up @@ -167,6 +177,7 @@ class ClientTimeout:

# https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"})
HTTP_SCHEMA_SET = frozenset({"http", "https", ""})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bdraco @setla would augmenting this make it work?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will analyze how its being used in the case I posted above when I get back home next week and provide additional detail

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a possible future improvement, I'd rename the constant to something like SUPPORTED_URI_SCHEMAS. Especially, if it'll stop doing listing http only.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have two constants where one lists ws/wss and the check asserts against both in the first place but not the second one. Are websockets supposed to be disallowed in redirects?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since ws_connect always calls into self.request think the simplest solution to avoid a breaking change is:

diff --git a/aiohttp/client.py b/aiohttp/client.py
index 8d8d13f2..d608c009 100644
--- a/aiohttp/client.py
+++ b/aiohttp/client.py
@@ -178,7 +178,7 @@ DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=5 * 60)
 
 # https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2
 IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"})
-HTTP_SCHEMA_SET = frozenset({"http", "https", ""})
+HTTP_SCHEMA_SET = frozenset({"http", "https", "ws", "wss", ""})
 
 _RetType = TypeVar("_RetType")
 _CharsetResolver = Callable[[ClientResponse, bytes], str]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably want that in the _request() check, but probably still want to limit it to http/https in the redirects, and disable redirects in _ws_connect() (as mentioned above).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me. So it seems we need to different constants

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should include wss:// as well as this breaks discord.py entirely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably lost track of this, maybe nobody got round to making the change discussed above.


_RetType = TypeVar("_RetType")
_CharsetResolver = Callable[[ClientResponse, bytes], str]
Expand Down Expand Up @@ -404,7 +415,10 @@ async def _request(
try:
url = self._build_url(str_or_url)
except ValueError as e:
raise InvalidURL(str_or_url) from e
raise InvalidUrlClientError(str_or_url) from e

if url.scheme not in HTTP_SCHEMA_SET:
raise NonHttpUrlClientError(url)

skip_headers = set(self._skip_auto_headers)
if skip_auto_headers is not None:
Expand Down Expand Up @@ -459,6 +473,15 @@ async def _request(
retry_persistent_connection = method in IDEMPOTENT_METHODS
while True:
url, auth_from_url = strip_auth_from_url(url)
if not url.raw_host:
# NOTE: Bail early, otherwise, causes `InvalidURL` through
# NOTE: `self._request_class()` below.
err_exc_cls = (
InvalidUrlRedirectClientError
if redirects
else InvalidUrlClientError
)
raise err_exc_cls(url)
if auth and auth_from_url:
raise ValueError(
"Cannot combine AUTH argument with "
Expand Down Expand Up @@ -611,34 +634,44 @@ async def _request(
resp.release()

try:
parsed_url = URL(
parsed_redirect_url = URL(
r_url, encoded=not self._requote_redirect_url
)

except ValueError as e:
raise InvalidURL(r_url) from e
raise InvalidUrlRedirectClientError(
r_url,
"Server attempted redirecting to a location that does not look like a URL",
) from e

scheme = parsed_url.scheme
if scheme not in ("http", "https", ""):
scheme = parsed_redirect_url.scheme
if scheme not in HTTP_SCHEMA_SET:
resp.close()
raise ValueError("Can redirect only to http or https")
raise NonHttpUrlRedirectClientError(r_url)
elif not scheme:
parsed_url = url.join(parsed_url)
parsed_redirect_url = url.join(parsed_redirect_url)

is_same_host_https_redirect = (
url.host == parsed_url.host
and parsed_url.scheme == "https"
url.host == parsed_redirect_url.host
and parsed_redirect_url.scheme == "https"
and url.scheme == "http"
)

try:
redirect_origin = parsed_redirect_url.origin()
except ValueError as origin_val_err:
raise InvalidUrlRedirectClientError(
parsed_redirect_url,
"Invalid redirect URL origin",
) from origin_val_err

if (
url.origin() != parsed_url.origin()
url.origin() != redirect_origin
and not is_same_host_https_redirect
):
auth = None
headers.pop(hdrs.AUTHORIZATION, None)

url = parsed_url
url = parsed_redirect_url
params = {}
resp.release()
continue
Expand Down
54 changes: 47 additions & 7 deletions aiohttp/client_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""HTTP related errors."""

import asyncio
from typing import TYPE_CHECKING, Any, Optional, Tuple, Union
from typing import TYPE_CHECKING, Optional, Tuple, Union

from .http_parser import RawResponseMessage
from .typedefs import LooseHeaders
from .typedefs import LooseHeaders, StrOrURL

try:
import ssl
Expand Down Expand Up @@ -40,6 +40,11 @@
"ContentTypeError",
"ClientPayloadError",
"InvalidURL",
"InvalidUrlClientError",
"RedirectClientError",
"NonHttpUrlClientError",
"InvalidUrlRedirectClientError",
"NonHttpUrlRedirectClientError",
)


Expand Down Expand Up @@ -248,17 +253,52 @@ class InvalidURL(ClientError, ValueError):

# Derive from ValueError for backward compatibility

def __init__(self, url: Any) -> None:
def __init__(self, url: StrOrURL, description: Union[str, None] = None) -> None:
# The type of url is not yarl.URL because the exception can be raised
# on URL(url) call
setla marked this conversation as resolved.
Show resolved Hide resolved
super().__init__(url)
self._url = url
self._description = description

if description:
super().__init__(url, description)
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
else:
super().__init__(url)

@property
def url(self) -> StrOrURL:
return self._url

@property
def url(self) -> Any:
return self.args[0]
def description(self) -> "str | None":
return self._description

def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.url}>"
return f"<{self.__class__.__name__} {self}>"

def __str__(self) -> str:
if self._description:
return f"{self._url} - {self._description}"
Dreamsorcerer marked this conversation as resolved.
Show resolved Hide resolved
return str(self._url)


class InvalidUrlClientError(InvalidURL):
"""Invalid URL client error."""


class RedirectClientError(ClientError):
"""Client redirect error."""


class NonHttpUrlClientError(ClientError):
"""Non http URL client error."""


class InvalidUrlRedirectClientError(InvalidUrlClientError, RedirectClientError):
"""Invalid URL redirect client error."""


class NonHttpUrlRedirectClientError(NonHttpUrlClientError, RedirectClientError):
"""Non http URL redirect client error."""


class ClientSSLError(ClientConnectorError):
Expand Down
49 changes: 49 additions & 0 deletions docs/client_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2099,6 +2099,41 @@ All exceptions are available as members of *aiohttp* module.

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

.. attribute:: description

Invalid URL description, :class:`str` instance or :data:`None`.

.. exception:: InvalidUrlClientError

Base class for all errors related to client url.

Derived from :exc:`InvalidURL`

.. exception:: RedirectClientError

Base class for all errors related to client redirects.

Derived from :exc:`ClientError`

.. exception:: NonHttpUrlClientError

Base class for all errors related to non http client urls.

Derived from :exc:`ClientError`

.. exception:: InvalidUrlRedirectClientError

Redirect URL is malformed, e.g. it does not contain host part.

Derived from :exc:`InvalidUrlClientError` and :exc:`RedirectClientError`

.. exception:: NonHttpUrlRedirectClientError

Redirect URL does not contain http schema.

Derived from :exc:`RedirectClientError` and :exc:`NonHttpUrlClientError`


.. class:: ContentDisposition

Represent Content-Disposition header
Expand Down Expand Up @@ -2315,3 +2350,17 @@ Hierarchy of exceptions
* :exc:`WSServerHandshakeError`

* :exc:`InvalidURL`

* :exc:`InvalidUrlClientError`

* :exc:`InvalidUrlRedirectClientError`

* :exc:`NonHttpUrlClientError`

* :exc:`NonHttpUrlRedirectClientError`

* :exc:`RedirectClientError`
setla marked this conversation as resolved.
Show resolved Hide resolved

* :exc:`InvalidUrlRedirectClientError`

* :exc:`NonHttpUrlRedirectClientError`
26 changes: 23 additions & 3 deletions tests/test_client_exceptions.py
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pickle
from typing import Any

from yarl import URL

from aiohttp import client, client_reqrep


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

class TestInvalidURL:
def test_ctor(self) -> None:
err = client.InvalidURL(url=":wrong:url:")
err = client.InvalidURL(url=":wrong:url:", description=":description:")
assert err.url == ":wrong:url:"
assert err.description == ":description:"

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

def test_repr(self) -> None:
def test_repr_no_description(self) -> None:
err = client.InvalidURL(url=":wrong:url:")
assert err.args == (":wrong:url:",)
assert repr(err) == "<InvalidURL :wrong:url:>"
Dreamsorcerer marked this conversation as resolved.
Show resolved Hide resolved

def test_str(self) -> None:
def test_repr_yarl_URL(self) -> None:
err = client.InvalidURL(url=URL(":wrong:url:"))
assert repr(err) == "<InvalidURL :wrong:url:>"

def test_repr_with_description(self) -> None:
err = client.InvalidURL(url=":wrong:url:", description=":description:")
assert repr(err) == "<InvalidURL :wrong:url: - :description:>"

def test_str_no_description(self) -> None:
err = client.InvalidURL(url=":wrong:url:")
assert str(err) == ":wrong:url:"

def test_none_description(self) -> None:
err = client.InvalidURL(":wrong:url:")
assert err.description is None

def test_str_with_description(self) -> None:
err = client.InvalidURL(url=":wrong:url:", description=":description:")
assert str(err) == ":wrong:url: - :description:"
Loading
Loading