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
- Percent-encode literal '%' and other special characters before building the URL: use `urllib.parse.quote(value, safe='')` on each path/query component.
- Pass query values via the `params=` argument of `requests.get()` so requests encodes them for you instead of concatenating them into the URL.
- If a template placeholder like '%zz' was left in the URL, fill or remove it before sending.
- 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
- Percent-encode user-supplied URL components with urllib.parse.quote/quote_plus before building the URL
- Never concatenate raw user input into URLs; build them with urllib.parse.urlencode for query strings
- Validate that every '%' in a URL is followed by two hex digits before sending
- Prefer passing query parameters via the params= argument so requests encodes them for you
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
- Invalid URL {url!r}: No host supplied
- Invalid URL {url!r}: No scheme supplied. Perhaps you meant h
- URL has an invalid label.
- No connection adapters were found for {url!r}
- Unable to find acceptable character detection dependency (ch
AI-assisted analysis of psf/requests@414f0513c3 (2026-07-31).
Data as JSON: /data/errors/143063c1189eaac1.json.
Report an issue: GitHub ↗.