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
38 changes: 38 additions & 0 deletions httpx/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,44 @@ def has_redirect_location(self) -> bool:
and "Location" in self.headers
)

@property
def retry_after(self) -> int | datetime.datetime | None:
"""
Parse the Retry-After header and return the recommended retry delay.

Returns:
- An integer (seconds) if the header contains a delay in seconds
- A datetime object if the header contains an HTTP date
- None if the header is not present or cannot be parsed

The Retry-After header is commonly used in:
- 429 (Too Many Requests) responses to indicate rate limiting
- 503 (Service Unavailable) responses to indicate when service may be available

Example:
>>> response.status_code
429
>>> response.retry_after
60 # Retry after 60 seconds
"""
retry_header = self.headers.get("Retry-After")
if retry_header is None:
return None

# Try parsing as an integer (delay-seconds)
try:
return int(retry_header)
except ValueError:
pass

# Try parsing as HTTP-date
try:
# Parse HTTP date format: "Wed, 21 Oct 2015 07:28:00 GMT"
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(retry_header)
except (ValueError, TypeError):
return None

def raise_for_status(self) -> Response:
"""
Raise the `HTTPStatusError` if one occurred.
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