diff --git a/httpx/__init__.py b/httpx/__init__.py index e9addde071..bf03d9fae0 100644 --- a/httpx/__init__.py +++ b/httpx/__init__.py @@ -10,6 +10,7 @@ from ._transports import * from ._types import * from ._urls import * +from ._utils import normalize_header_key try: from ._main import main @@ -63,6 +64,7 @@ def main() -> None: # type: ignore "MockTransport", "NetRCAuth", "NetworkError", + "normalize_header_key", "options", "patch", "PoolTimeout", diff --git a/httpx/_models.py b/httpx/_models.py index 2cc86321a4..257e43ab88 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -831,6 +831,67 @@ def raise_for_status(self) -> Response: def json(self, **kwargs: typing.Any) -> typing.Any: return jsonlib.loads(self.content, **kwargs) + def json_safe( + self, + default: typing.Any = None, + *, + raise_for_status: bool = True, + **kwargs: typing.Any, + ) -> typing.Any: + """ + Safely parse JSON response content with error handling. + + Unlike the standard `json()` method, this method provides graceful error + handling for common failure cases when parsing JSON responses. + + Args: + default: Value to return if JSON parsing fails or response is empty. + Defaults to None. + raise_for_status: If True (default), raises HTTPStatusError for 4xx/5xx + responses before attempting to parse JSON. If False, attempts + to parse JSON regardless of status code. + **kwargs: Additional arguments passed to json.loads() + + Returns: + The parsed JSON data, or the default value if parsing fails. + + Example: + >>> response = httpx.get("https://api.example.com/data") + >>> data = response.json_safe(default={}) # Returns {} if parsing fails + >>> # Handle rate limiting gracefully + >>> response = httpx.get("https://api.example.com/endpoint") + >>> data = response.json_safe(default={"error": "rate limited"}, raise_for_status=False) + + Note: + This method is particularly useful when: + - Dealing with unreliable APIs that may return malformed JSON + - You want a default value instead of raising exceptions + - You need to handle both HTTP errors and JSON parse errors uniformly + """ + # Check status code if requested + if raise_for_status and self.is_error: + request = self._request + if request is None: + raise RuntimeError( + "Cannot call `json_safe` with raise_for_status=True " + "as the request instance has not been set on this response." + ) + raise HTTPStatusError( + f"HTTP error {self.status_code} while requesting {request.url}", + request=request, + response=self, + ) + + # Return default for empty content + if not self.content: + return default + + # Try to parse JSON + try: + return jsonlib.loads(self.content, **kwargs) + except (jsonlib.JSONDecodeError, UnicodeDecodeError, ValueError): + return default + @property def cookies(self) -> Cookies: if not hasattr(self, "_cookies"): diff --git a/httpx/_utils.py b/httpx/_utils.py index 7fe827da4d..0ea77218b0 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -227,6 +227,10 @@ def __eq__(self, other: typing.Any) -> bool: def is_ipv4_hostname(hostname: str) -> bool: + """ + Check if the given hostname is a valid IPv4 address. + Supports CIDR notation by checking only the address part. + """ try: ipaddress.IPv4Address(hostname.split("/")[0]) except Exception: @@ -235,8 +239,53 @@ def is_ipv4_hostname(hostname: str) -> bool: def is_ipv6_hostname(hostname: str) -> bool: + """ + Check if the given hostname is a valid IPv6 address. + Supports CIDR notation by checking only the address part. + """ try: ipaddress.IPv6Address(hostname.split("/")[0]) except Exception: return False return True + + +def is_ip_address(hostname: str) -> bool: + """ + Check if the given hostname is a valid IP address (either IPv4 or IPv6). + Supports CIDR notation by checking only the address part. + """ + return is_ipv4_hostname(hostname) or is_ipv6_hostname(hostname) + + +def normalize_header_key(key: str, *, preserve_case: bool = False) -> str: + """ + Normalize HTTP header keys for consistent comparison and storage. + + By default, converts header keys to lowercase following HTTP/2 conventions. + Can optionally preserve the original case for HTTP/1.1 compatibility. + + Args: + key: The header key to normalize + preserve_case: If True, preserve the original case. If False (default), + convert to lowercase. + + Returns: + The normalized header key as a string + + Examples: + >>> normalize_header_key("Content-Type") + 'content-type' + >>> normalize_header_key("Content-Type", preserve_case=True) + 'Content-Type' + >>> normalize_header_key("X-Custom-Header") + 'x-custom-header' + + Note: + This function is useful when working with HTTP headers across different + protocol versions. HTTP/2 requires lowercase header names, while HTTP/1.1 + traditionally uses title-case headers (though comparison is case-insensitive). + """ + if preserve_case: + return key.strip() + return key.strip().lower()