{"id":"143063c1189eaac1","repo":"psf/requests","slug":"invalid-percent-escape-sequence-h","errorCode":null,"errorMessage":"Invalid percent-escape sequence: '{h}'","messagePattern":"Invalid percent-escape sequence: '(.+?)'","errorType":"exception","errorClass":"InvalidURL","httpStatus":null,"severity":"error","filePath":"src/requests/utils.py","lineNumber":693,"sourceCode":"UNRESERVED_SET: Final = frozenset(\n    \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\" + \"0123456789-._~\"\n)\n\n\ndef unquote_unreserved(uri: str) -> str:\n    \"\"\"Un-escape any percent-escape sequences in a URI that are unreserved\n    characters. This leaves all reserved, illegal and non-ASCII bytes encoded.\n\n    :rtype: str\n    \"\"\"\n    parts = uri.split(\"%\")\n    for i in range(1, len(parts)):\n        h = parts[i][0:2]\n        if len(h) == 2 and h.isalnum():\n            try:\n                c = chr(int(h, 16))\n            except ValueError:\n                raise InvalidURL(f\"Invalid percent-escape sequence: '{h}'\")\n\n            if c in UNRESERVED_SET:\n                parts[i] = c + parts[i][2:]\n            else:\n                parts[i] = f\"%{parts[i]}\"\n        else:\n            parts[i] = f\"%{parts[i]}\"\n    return \"\".join(parts)\n\n\ndef requote_uri(uri: str) -> str:\n    \"\"\"Re-quote the given URI.\n\n    This function passes the given URI through an unquote/quote cycle to\n    ensure that it is fully and consistently quoted.\n\n    :rtype: str\n    \"\"\"","sourceCodeStart":675,"sourceCodeEnd":711,"githubUrl":"https://github.com/psf/requests/blob/414f0513c33883adf6f2b46901d4f0b38a455851/src/requests/utils.py#L675-L711","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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`."],"exampleFix":"# before\nrequests.get('http://api.example.com/search?q=50%off')\n# after\nfrom urllib.parse import quote\nrequests.get('http://api.example.com/search', params={'q': '50%off'})  # requests encodes '%' as %25","handlingStrategy":"validation","validationCode":"import re\ndef has_valid_percent_escapes(uri):\n    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\n\ndef safe_uri(uri):\n    return all(re.match(r'^[0-9A-Fa-f]{2}', uri[i+1:i+3]) for i, c in enumerate(uri) if c == '%')\n\nif not safe_uri(url):\n    from urllib.parse import quote\n    url = quote(url, safe='/:?=&%')","typeGuard":null,"tryCatchPattern":"try:\n    resp = requests.get(url)\nexcept requests.exceptions.InvalidURL as e:\n    # malformed percent-escape in URL, e.g. a literal '%' not followed by two hex digits\n    log.error('Bad URL %r: %s', url, e)","preventionTips":["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"],"tags":["url","encoding","invalid-url","python","requests"],"analyzedSha":"414f0513c33883adf6f2b46901d4f0b38a455851","analyzedAt":"2026-07-31T19:24:57.696Z","schemaVersion":2}