psf/requests · error · InvalidURL

Invalid percent-escape sequence

Error message

Invalid percent-escape sequence: '{h}'

What it means

Raised by unquote_unreserved() when a percent-escape sequence in a URL contains characters that pass isalnum() but fail int(..., 16) conversion. This is extremely rare since isalnum + length-2 usually implies valid hex, but certain Unicode characters (e.g., non-ASCII letters) can be alphanumeric without being valid hexadecimal digits.

Solutions

  1. Sanitize/validate URLs before passing to requests: check for valid percent-encoding with a regex.
  2. Use urllib.parse.quote on user-supplied path components to ensure proper encoding.
  3. Catch requests.exceptions.InvalidURL and fall back to a quoted version of the URL.

Example fix

# before
requests.get('https://example.com/path%ZZbad')  # potential InvalidURL

# after — sanitize the URL first
from urllib.parse import quote, unquote
safe_url = quote(unquote(url), safe=':/?&=%')
requests.get(safe_url)
Defensive patterns

Strategy: validation

Validate before calling

import re
if re.search(r'%[^0-9A-Fa-f]', url):
    from urllib.parse import quote, unquote
    url = quote(unquote(url, errors='replace'), safe=':/?&=%@[]')
resp = requests.get(url)

Type guard

import re
def has_valid_percent_encoding(url: str) -> bool:
    return not re.search(r'%[0-9A-Fa-f]{0,1}[^0-9A-Fa-f]', url)

Try / catch

try:
    resp = requests.get(url)
except requests.exceptions.InvalidURL:
    from urllib.parse import quote
    resp = requests.get(quote(url, safe=':/?&=%@[]'))

Prevention

When it happens

Trigger: A URL containing a percent sign followed by non-ASCII alphanumeric characters (e.g., Unicode digits or letters from other scripts); extremely malformed percent-encoding in user-supplied URLs that gets past the isalnum() check due to Unicode properties.

Common situations: User-supplied URLs with Unicode characters after a percent sign; URLs copied from internationalized sources with encoding issues; extremely rare in practice because most malformed sequences fall into the else branch (just re-quoted).

Related errors


AI-assisted analysis of psf/requests@8068356288 (2026-08-11). Data as JSON: /api/errors/143063c1189eaac1. Report an issue: GitHub.

Appendix: source

Thrown at src/requests/utils.py:693

UNRESERVED_SET: Final = frozenset(
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123456789-._~"
)


def unquote_unreserved(uri: str) -> str:
    """Un-escape any percent-escape sequences in a URI that are unreserved
    characters. This leaves all reserved, illegal and non-ASCII bytes encoded.

    :rtype: str
    """
    parts = uri.split("%")
    for i in range(1, len(parts)):
        h = parts[i][0:2]
        if len(h) == 2 and h.isalnum():
            try:
                c = chr(int(h, 16))
            except ValueError:
                raise InvalidURL(f"Invalid percent-escape sequence: '{h}'")

            if c in UNRESERVED_SET:
                parts[i] = c + parts[i][2:]
            else:
                parts[i] = f"%{parts[i]}"
        else:
            parts[i] = f"%{parts[i]}"
    return "".join(parts)


def requote_uri(uri: str) -> str:
    """Re-quote the given URI.

    This function passes the given URI through an unquote/quote cycle to
    ensure that it is fully and consistently quoted.

    :rtype: str
    """

View on GitHub (pinned to 8068356288)