diff --git a/curl_cffi/curl.py b/curl_cffi/curl.py index 3473ab8..12f7ff8 100644 --- a/curl_cffi/curl.py +++ b/curl_cffi/curl.py @@ -2,12 +2,12 @@ import warnings from http.cookies import SimpleCookie from pathlib import Path -from typing import Any, List, Optional, Tuple, Union +from typing import Any, List, Literal, Optional, Tuple, Union import certifi from ._wrapper import ffi, lib -from .const import CurlHttpVersion, CurlInfo, CurlOpt, CurlWsFlag +from .const import CurlECode, CurlHttpVersion, CurlInfo, CurlOpt, CurlWsFlag DEFAULT_CACERT = certifi.where() REASON_PHRASE_RE = re.compile(rb"HTTP/\d\.\d [0-9]{3} (.*)") @@ -17,9 +17,9 @@ class CurlError(Exception): """Base exception for curl_cffi package""" - def __init__(self, msg, code: int = 0, *args, **kwargs): + def __init__(self, msg, code: Union[CurlECode, Literal[0]] = 0, *args, **kwargs): super().__init__(msg, *args, **kwargs) - self.code = code + self.code: Union[CurlECode, Literal[0]] = code CURLINFO_TEXT = 0 diff --git a/curl_cffi/requests/exceptions.py b/curl_cffi/requests/exceptions.py index c686c68..430ee2e 100644 --- a/curl_cffi/requests/exceptions.py +++ b/curl_cffi/requests/exceptions.py @@ -1,16 +1,19 @@ # Apache 2.0 License # Vendored from https://github.com/psf/requests/blob/main/src/requests/exceptions.py +# With our own addtions import json +from typing import Literal, Union from .. import CurlError +from ..const import CurlECode # Note IOError is an alias of OSError in Python 3.x class RequestException(CurlError, OSError): """Base exception for curl_cffi.requests package""" - def __init__(self, msg, code=0, response=None, *args, **kwargs): + def __init__(self, msg, code: Union[CurlECode, Literal[0]] = 0, response=None, *args, **kwargs): super().__init__(msg, code, *args, **kwargs) self.response = response @@ -27,10 +30,12 @@ class ImpersonateError(RequestException): """The impersonate config was wrong or impersonate failed.""" +# not used class InvalidJSONError(RequestException): """A JSON error occurred.""" +# not used class JSONDecodeError(InvalidJSONError, json.JSONDecodeError): """Couldn't decode the text into json""" @@ -43,6 +48,10 @@ class ConnectionError(RequestException): """A Connection error occurred.""" +class DNSError(ConnectionError): + """Could not resolve""" + + class ProxyError(RequestException): """A proxy error occurred.""" @@ -51,10 +60,15 @@ class SSLError(ConnectionError): """An SSL error occurred.""" +class CertificateVerifyError(SSLError): + """Raised when certificate validated has failed""" + + class Timeout(RequestException): """The request timed out.""" +# not used class ConnectTimeout(ConnectionError, Timeout): """The request timed out while trying to connect to the remote server. @@ -62,10 +76,12 @@ class ConnectTimeout(ConnectionError, Timeout): """ +# not used class ReadTimeout(Timeout): """The server did not send any data in the allotted amount of time.""" +# not used class URLRequired(RequestException): """A valid URL is required to make a request.""" @@ -74,6 +90,7 @@ class TooManyRedirects(RequestException): """Too many redirects.""" +# not used class MissingSchema(RequestException, ValueError): """The URL scheme (e.g. http or https) is missing.""" @@ -86,44 +103,110 @@ class InvalidURL(RequestException, ValueError): """The URL provided was somehow invalid.""" +# not used class InvalidHeader(RequestException, ValueError): """The header value provided was somehow invalid.""" +# not used class InvalidProxyURL(InvalidURL): """The proxy URL provided is invalid.""" +# not used class ChunkedEncodingError(RequestException): """The server declared chunked encoding but sent an invalid chunk.""" +# not used class ContentDecodingError(RequestException): """Failed to decode response content.""" +# not used class StreamConsumedError(RequestException, TypeError): """The content for this response was already consumed.""" +# does not support class RetryError(RequestException): """Custom retries logic failed""" +# not used class UnrewindableBodyError(RequestException): """Requests encountered an error when trying to rewind a body.""" +class InterfaceError(RequestException): + """A specified outgoing interface could not be used.""" + + # Warnings +# not used class RequestsWarning(Warning): """Base warning for Requests.""" +# not used class FileModeWarning(RequestsWarning, DeprecationWarning): """A file was opened in text mode, but Requests determined its binary length.""" +# not used class RequestsDependencyWarning(RequestsWarning): """An imported dependency doesn't match the expected version range.""" + + +CODE2ERROR = { + 0: RequestException, + CurlECode.UNSUPPORTED_PROTOCOL: InvalidSchema, + CurlECode.URL_MALFORMAT: InvalidURL, + CurlECode.COULDNT_RESOLVE_PROXY: ProxyError, + CurlECode.COULDNT_RESOLVE_HOST: DNSError, + CurlECode.COULDNT_CONNECT: ConnectionError, + CurlECode.WEIRD_SERVER_REPLY: ConnectionError, + CurlECode.REMOTE_ACCESS_DENIED: ConnectionError, + CurlECode.HTTP2: HTTPError, + CurlECode.HTTP_RETURNED_ERROR: HTTPError, + CurlECode.WRITE_ERROR: RequestException, + CurlECode.READ_ERROR: RequestException, + CurlECode.OUT_OF_MEMORY: RequestException, + CurlECode.OPERATION_TIMEDOUT: TimeoutError, + CurlECode.SSL_CONNECT_ERROR: SSLError, + CurlECode.INTERFACE_FAILED: InterfaceError, + CurlECode.TOO_MANY_REDIRECTS: TooManyRedirects, + CurlECode.UNKNOWN_OPTION: RequestException, + CurlECode.SETOPT_OPTION_SYNTAX: RequestException, + CurlECode.GOT_NOTHING: ConnectionError, + CurlECode.SSL_ENGINE_NOTFOUND: SSLError, + CurlECode.SSL_ENGINE_SETFAILED: SSLError, + CurlECode.SEND_ERROR: ConnectionError, + CurlECode.RECV_ERROR: ConnectionError, + CurlECode.SSL_CERTPROBLEM: SSLError, + CurlECode.SSL_CIPHER: SSLError, + CurlECode.PEER_FAILED_VERIFICATION: CertificateVerifyError, + CurlECode.BAD_CONTENT_ENCODING: HTTPError, + CurlECode.SSL_ENGINE_INITFAILED: SSLError, + CurlECode.SSL_CACERT_BADFILE: SSLError, + CurlECode.SSL_CRL_BADFILE: SSLError, + CurlECode.SSL_ISSUER_ERROR: SSLError, + CurlECode.SSL_PINNEDPUBKEYNOTMATCH: SSLError, + CurlECode.SSL_INVALIDCERTSTATUS: SSLError, + CurlECode.HTTP2_STREAM: HTTPError, + CurlECode.HTTP3: HTTPError, + CurlECode.QUIC_CONNECT_ERROR: ConnectionError, + CurlECode.PROXY: ProxyError, + CurlECode.SSL_CLIENTCERT: SSLError, + CurlECode.ECH_REQUIRED: SSLError, +} + + +# credits: https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/networking/_curlcffi.py#L241 +# Unlicense +def code2error(code: Union[CurlECode, Literal[0]], msg: str): + if code == CurlECode.RECV_ERROR and "CONNECT" in msg: + return ProxyError + return CODE2ERROR.get(code, RequestException) diff --git a/curl_cffi/requests/session.py b/curl_cffi/requests/session.py index f81e8bb..5d90041 100644 --- a/curl_cffi/requests/session.py +++ b/curl_cffi/requests/session.py @@ -29,7 +29,7 @@ from .. import AsyncCurl, Curl, CurlError, CurlHttpVersion, CurlInfo, CurlOpt, CurlSslVersion from ..curl import CURL_WRITEFUNC_ERROR, CurlMime from .cookies import Cookies, CookieTypes, CurlMorsel -from .exceptions import ImpersonateError, RequestException, SessionClosed +from .exceptions import ImpersonateError, RequestException, SessionClosed, code2error from .headers import Headers, HeaderTypes from .impersonate import BrowserType # noqa: F401 from .impersonate import ( @@ -1026,7 +1026,8 @@ def cleanup(fut): except CurlError as e: rsp = self._parse_response(c, buffer, header_buffer, default_encoding) rsp.request = req - raise RequestException(str(e), e.code, rsp) from e + error = code2error(e.code, str(e)) + raise error(str(e), e.code, rsp) from e else: rsp = self._parse_response(c, buffer, header_buffer, default_encoding) rsp.request = req @@ -1306,7 +1307,8 @@ def cleanup(fut): except CurlError as e: rsp = self._parse_response(curl, buffer, header_buffer, default_encoding) rsp.request = req - raise RequestException(str(e), e.code, rsp) from e + error = code2error(e.code, str(e)) + raise error(str(e), e.code, rsp) from e else: rsp = self._parse_response(curl, buffer, header_buffer, default_encoding) rsp.request = req