Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions httpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ._transports import *
from ._types import *
from ._urls import *
from ._utils import normalize_header_key

try:
from ._main import main
Expand Down Expand Up @@ -63,6 +64,7 @@ def main() -> None: # type: ignore
"MockTransport",
"NetRCAuth",
"NetworkError",
"normalize_header_key",
"options",
"patch",
"PoolTimeout",
Expand Down
61 changes: 61 additions & 0 deletions httpx/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
49 changes: 49 additions & 0 deletions httpx/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Loading