From ba2274031f4fc58cd1a8cb0f6642ab4ac9566ce4 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Tue, 7 Oct 2025 19:40:01 +0100 Subject: [PATCH 1/4] Add docstrings to IP validation helper functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation to is_ipv4_hostname and is_ipv6_hostname functions to clarify their purpose and CIDR notation support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- httpx/_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/httpx/_utils.py b/httpx/_utils.py index 7fe827da4d..1093872dce 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,6 +239,10 @@ 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: From 5bf0ece7d438087b14504661f1fef63a7607ca88 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Tue, 7 Oct 2025 23:05:27 +0100 Subject: [PATCH 2/4] Add is_ip_address function to validate IP addresses Introduced a new helper function, is_ip_address, to check if a given hostname is a valid IP address (either IPv4 or IPv6), including support for CIDR notation. This enhances the utility of the IP validation module. --- httpx/_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/httpx/_utils.py b/httpx/_utils.py index 1093872dce..20563a5ec1 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -248,3 +248,11 @@ def is_ipv6_hostname(hostname: str) -> bool: 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) From 2855333afe0876c729979c50dade1427e212dedc Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Wed, 8 Oct 2025 00:10:28 +0100 Subject: [PATCH 3/4] Add normalize_header_key function for consistent HTTP header key handling Introduced a new utility function, normalize_header_key, to standardize HTTP header keys for comparison and storage. This function normalizes keys to lowercase by default, with an option to preserve the original case for compatibility with HTTP/1.1. Updated __all__ to include the new function. --- httpx/__init__.py | 2 ++ httpx/_utils.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) 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/_utils.py b/httpx/_utils.py index 20563a5ec1..0ea77218b0 100644 --- a/httpx/_utils.py +++ b/httpx/_utils.py @@ -256,3 +256,36 @@ def is_ip_address(hostname: str) -> bool: 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() From 877571acc010814ca587296c3a7f6356965ba3e3 Mon Sep 17 00:00:00 2001 From: Andrey Lebedev Date: Wed, 8 Oct 2025 00:31:42 +0100 Subject: [PATCH 4/4] Add retry_after property to Response class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new retry_after property that parses the Retry-After HTTP header and returns the recommended retry delay as either an integer (seconds) or a datetime object (for HTTP-date format). This is useful for handling rate limiting (429) and service unavailable (503) responses. The property returns None if the header is missing or cannot be parsed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- httpx/_models.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) 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.