philiptzou: Rebased onto the latest version.
philiptzou: Rebased onto the latest version. Again.
kgriffs: @philiptzou Nice work! Just a couple of nits to address and this should be ready to merge.
philiptzou: @kgriffs Sorry for the delay. Just fixed the issues you addressed.
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
index f014e5a..2359672 100644
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -178,9 +178,12 @@ if six.PY2:
         tokens = decoded_uri.split('%')
         decoded_uri = tokens[0]
         for token in tokens[1:]:
-            char, byte = _HEX_TO_BYTE[token[:2]]
-            decoded_uri += char + token[2:]
-
+            token_partial = token[:2]
+            if token_partial in _HEX_TO_BYTE:
+                char, byte = _HEX_TO_BYTE[token_partial]
+            else:
+                char, byte = '%', 0
+            decoded_uri += char + (token[2:] if byte else token)
             only_ascii = only_ascii and (byte <= 127)
 
         # PERF(kgriffs): Only spend the time to do this if there
@@ -235,7 +238,12 @@ else:
         tokens = decoded_uri.split(b'%')
         decoded_uri = tokens[0]
         for token in tokens[1:]:
-            decoded_uri += _HEX_TO_BYTE[token[:2]] + token[2:]
+            token_partial = token[:2]
+            if token_partial in _HEX_TO_BYTE:
+                decoded_uri += _HEX_TO_BYTE[token_partial] + token[2:]
+            else:
+                # malformed percentage like "x=%" or "y=%+"
+                decoded_uri += b'%' + token
 
         # Convert back to str
         return decoded_uri.decode('utf-8', 'replace')

Falcon crashes on decoding query string
When Falcon receives a request with query string that cannot be percent decoded (e.g. `x=%`), it crashes resulting in a rather unexpected 502 response.

	Traceback (most recent call last):
	  File "/opt/ads/venv/local/lib/python2.7/site-packages/falcon/api.py", line 154, in __call__
		req = self._request_type(env, options=self.req_options)
	  File "/opt/ads/venv/local/lib/python2.7/site-packages/falcon/request.py", line 237, in __init__
		keep_blank_qs_values=self.options.keep_blank_qs_values,
	  File "/opt/ads/venv/local/lib/python2.7/site-packages/falcon/util/uri.py", line 327, in parse_query_string
		params[k] = decode(v)
	  File "/opt/ads/venv/local/lib/python2.7/site-packages/falcon/util/uri.py", line 181, in decode
		char, byte = _HEX_TO_BYTE[token[:2]]
	KeyError: ''

Would it make more sense to pass-through the original value in this case without trying to decode it? It's a rather small change so I could prepare a pull request for it, but I would like to confirm first that this was not intentional behavior.
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index 5604e9a..50ed010 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -64,6 +64,16 @@ class _TestQueryParams(testing.TestBase):
         self.assertEqual(req.get_param_as_list('id', int), [23, 42])
         self.assertEqual(req.get_param('q'), u'\u8c46 \u74e3')
 
+    def test_bad_percentage(self):
+        query_string = 'x=%%20%+%&y=peregrine&z=%a%z%zz%1%20e'
+        self.simulate_request('/', query_string=query_string)
+        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+
+        req = self.resource.req
+        self.assertEqual(req.get_param('x'), '% % %')
+        self.assertEqual(req.get_param('y'), 'peregrine')
+        self.assertEqual(req.get_param('z'), '%a%z%zz%1 e')
+
     def test_allowed_names(self):
         query_string = ('p=0&p1=23&2p=foo&some-thing=that&blank=&'
                         'some_thing=x&-bogus=foo&more.things=blah&'

pip install -e .
parse_log_pytest
pytest
nose coverage ddt pyyaml requests testtools pytest
apt-get update apt-get install -y gcc
3.7
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///croot/attrs_1668696182826/work
certifi @ file:///croot/certifi_1671487769961/work/certifi
charset-normalizer==3.4.1
coverage==7.2.7
ddt==1.7.2
-e git+https://github.com/falconry/falcon.git@dcbde2512cac6f282bc209702c7477379c9546c1#egg=falcon
flit_core @ file:///opt/conda/conda-bld/flit-core_1644941570762/work/source/flit_core
idna==3.10
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1648562407465/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
nose==1.3.7
packaging @ file:///croot/packaging_1671697413597/work
pluggy @ file:///tmp/build/80754af9/pluggy_1648042572264/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pytest==7.1.2
python-mimeparse==1.6.0
PyYAML==6.0.1
requests==2.31.0
six==1.17.0
testtools==2.7.1
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
typing_extensions @ file:///croot/typing_extensions_1669924550328/work
urllib3==2.0.7
zipp @ file:///croot/zipp_1672387121353/work

name: falcon
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=22.1.0=py37h06a4308_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - flit-core=3.6.0=pyhd3eb1b0_0
  - importlib-metadata=4.11.3=py37h06a4308_0
  - importlib_metadata=4.11.3=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=22.0=py37h06a4308_0
  - pip=22.3.1=py37h06a4308_0
  - pluggy=1.0.0=py37h06a4308_1
  - py=1.11.0=pyhd3eb1b0_0
  - pytest=7.1.2=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py37h06a4308_0
  - typing_extensions=4.4.0=py37h06a4308_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.11.0=py37h06a4308_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - charset-normalizer==3.4.1
      - coverage==7.2.7
      - ddt==1.7.2
      - idna==3.10
      - nose==1.3.7
      - python-mimeparse==1.6.0
      - pyyaml==6.0.1
      - requests==2.31.0
      - six==1.17.0
      - testtools==2.7.1
      - urllib3==2.0.7
prefix: /opt/conda/envs/falcon

tests/test_query_params.py::_TestQueryParams::test_bad_percentage tests/test_query_params.py::PostQueryParams::test_bad_percentage tests/test_query_params.py::GetQueryParams::test_bad_percentage
tests/test_query_params.py::_TestQueryParams::test_allowed_names tests/test_query_params.py::_TestQueryParams::test_blank tests/test_query_params.py::_TestQueryParams::test_boolean tests/test_query_params.py::_TestQueryParams::test_boolean_blank tests/test_query_params.py::_TestQueryParams::test_get_date_invalid tests/test_query_params.py::_TestQueryParams::test_get_date_missing_param tests/test_query_params.py::_TestQueryParams::test_get_date_store tests/test_query_params.py::_TestQueryParams::test_get_date_valid tests/test_query_params.py::_TestQueryParams::test_get_date_valid_with_format tests/test_query_params.py::_TestQueryParams::test_int tests/test_query_params.py::_TestQueryParams::test_int_neg tests/test_query_params.py::_TestQueryParams::test_list_transformer tests/test_query_params.py::_TestQueryParams::test_list_type tests/test_query_params.py::_TestQueryParams::test_list_type_blank tests/test_query_params.py::_TestQueryParams::test_multiple_form_keys tests/test_query_params.py::_TestQueryParams::test_multiple_form_keys_as_list tests/test_query_params.py::_TestQueryParams::test_multiple_keys_as_bool tests/test_query_params.py::_TestQueryParams::test_multiple_keys_as_int tests/test_query_params.py::_TestQueryParams::test_none tests/test_query_params.py::_TestQueryParams::test_param_property tests/test_query_params.py::_TestQueryParams::test_percent_encoded tests/test_query_params.py::_TestQueryParams::test_required_1_get_param tests/test_query_params.py::_TestQueryParams::test_required_2_get_param_as_int tests/test_query_params.py::_TestQueryParams::test_required_3_get_param_as_bool tests/test_query_params.py::_TestQueryParams::test_required_4_get_param_as_list tests/test_query_params.py::_TestQueryParams::test_simple tests/test_query_params.py::PostQueryParams::test_allowed_names tests/test_query_params.py::PostQueryParams::test_blank tests/test_query_params.py::PostQueryParams::test_boolean tests/test_query_params.py::PostQueryParams::test_boolean_blank tests/test_query_params.py::PostQueryParams::test_get_date_invalid tests/test_query_params.py::PostQueryParams::test_get_date_missing_param tests/test_query_params.py::PostQueryParams::test_get_date_store tests/test_query_params.py::PostQueryParams::test_get_date_valid tests/test_query_params.py::PostQueryParams::test_get_date_valid_with_format tests/test_query_params.py::PostQueryParams::test_int tests/test_query_params.py::PostQueryParams::test_int_neg tests/test_query_params.py::PostQueryParams::test_list_transformer tests/test_query_params.py::PostQueryParams::test_list_type tests/test_query_params.py::PostQueryParams::test_list_type_blank tests/test_query_params.py::PostQueryParams::test_multiple_form_keys tests/test_query_params.py::PostQueryParams::test_multiple_form_keys_as_list tests/test_query_params.py::PostQueryParams::test_multiple_keys_as_bool tests/test_query_params.py::PostQueryParams::test_multiple_keys_as_int tests/test_query_params.py::PostQueryParams::test_non_ascii tests/test_query_params.py::PostQueryParams::test_none tests/test_query_params.py::PostQueryParams::test_param_property tests/test_query_params.py::PostQueryParams::test_percent_encoded tests/test_query_params.py::PostQueryParams::test_required_1_get_param tests/test_query_params.py::PostQueryParams::test_required_2_get_param_as_int tests/test_query_params.py::PostQueryParams::test_required_3_get_param_as_bool tests/test_query_params.py::PostQueryParams::test_required_4_get_param_as_list tests/test_query_params.py::PostQueryParams::test_simple tests/test_query_params.py::GetQueryParams::test_allowed_names tests/test_query_params.py::GetQueryParams::test_blank tests/test_query_params.py::GetQueryParams::test_boolean tests/test_query_params.py::GetQueryParams::test_boolean_blank tests/test_query_params.py::GetQueryParams::test_get_date_invalid tests/test_query_params.py::GetQueryParams::test_get_date_missing_param tests/test_query_params.py::GetQueryParams::test_get_date_store tests/test_query_params.py::GetQueryParams::test_get_date_valid tests/test_query_params.py::GetQueryParams::test_get_date_valid_with_format tests/test_query_params.py::GetQueryParams::test_int tests/test_query_params.py::GetQueryParams::test_int_neg tests/test_query_params.py::GetQueryParams::test_list_transformer tests/test_query_params.py::GetQueryParams::test_list_type tests/test_query_params.py::GetQueryParams::test_list_type_blank tests/test_query_params.py::GetQueryParams::test_multiple_form_keys tests/test_query_params.py::GetQueryParams::test_multiple_form_keys_as_list tests/test_query_params.py::GetQueryParams::test_multiple_keys_as_bool tests/test_query_params.py::GetQueryParams::test_multiple_keys_as_int tests/test_query_params.py::GetQueryParams::test_none tests/test_query_params.py::GetQueryParams::test_param_property tests/test_query_params.py::GetQueryParams::test_percent_encoded tests/test_query_params.py::GetQueryParams::test_required_1_get_param tests/test_query_params.py::GetQueryParams::test_required_2_get_param_as_int tests/test_query_params.py::GetQueryParams::test_required_3_get_param_as_bool tests/test_query_params.py::GetQueryParams::test_required_4_get_param_as_list tests/test_query_params.py::GetQueryParams::test_simple
falcon/util/uri.py
falcon/util/uri.py