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..e7506c0e90 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -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. 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()