psf/requests · error · InvalidURL

Invalid percent-escape sequence: '{h}'

Error message

Invalid percent-escape sequence: '{h}'

What it means

Raised as `requests.exceptions.InvalidURL` from `unquote_unreserved()` in `src/requests/utils.py:693` while requests normalizes ('requotes') a URL. The function splits the URL on '%' and tries to decode each two-character escape with `int(h, 16)`; if the two characters are alphanumeric but not valid hexadecimal (e.g. '%zz'), `chr(int(h, 16))` raises ValueError, which is converted to InvalidURL. It exists because a malformed percent-escape means the URL cannot be consistently re-quoted.

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 414f0513c3)

Solutions

  1. Percent-encode literal '%' and other special characters before building the URL: use `urllib.parse.quote(value, safe='')` on each path/query component.
  2. Pass query values via the `params=` argument of `requests.get()` so requests encodes them for you instead of concatenating them into the URL.
  3. If a template placeholder like '%zz' was left in the URL, fill or remove it before sending.
  4. If the bad sequence comes from a server redirect you don't control, catch `requests.exceptions.InvalidURL` and handle/repair the Location value manually with `allow_redirects=False`.

Example fix

# before
requests.get('http://api.example.com/search?q=50%off')
# after
from urllib.parse import quote
requests.get('http://api.example.com/search', params={'q': '50%off'})  # requests encodes '%' as %25
Defensive patterns

Strategy: validation

Validate before calling

import re
def has_valid_percent_escapes(uri):
    return all(re.fullmatch(r'%[0-9A-Fa-f]{2}', uri[m.start():m.start()+3] and uri[m.start():m.start()+3]) for m in re.finditer('%', uri)) if '%' in uri else True

def safe_uri(uri):
    return all(re.match(r'^[0-9A-Fa-f]{2}', uri[i+1:i+3]) for i, c in enumerate(uri) if c == '%')

if not safe_uri(url):
    from urllib.parse import quote
    url = quote(url, safe='/:?=&%')

Try / catch

try:
    resp = requests.get(url)
except requests.exceptions.InvalidURL as e:
    # malformed percent-escape in URL, e.g. a literal '%' not followed by two hex digits
    log.error('Bad URL %r: %s', url, e)

Prevention

When it happens

Trigger: Calling `requests.get()/post()` (or `PreparedRequest.prepare_url`) with a URL containing a '%' followed by two alphanumeric non-hex characters, e.g. `requests.get('http://host/path%zz')`. Also triggered indirectly when a server issues a redirect Location header containing such a sequence, since `requote_uri()` runs on redirect targets.

Common situations: URLs built by naive string concatenation where a literal '%' (e.g. in a query value like '100%zoom' or a Windows-style '%USERNAME%' placeholder) was never percent-encoded; template placeholders like '%s' or '%yyyy' left unfilled in a URL; user-supplied search strings containing '%' embedded raw into the path.

Related errors


AI-assisted analysis of psf/requests@414f0513c3 (2026-07-31). Data as JSON: /data/errors/143063c1189eaac1.json. Report an issue: GitHub ↗.