codecov-io: ## [Current coverage](https://codecov.io/gh/falconry/falcon/pull/869?src=pr) is 100% (diff: 100%)
> Merging [#869](https://codecov.io/gh/falconry/falcon/pull/869?src=pr) into [master](https://codecov.io/gh/falconry/falcon/branch/master?src=pr) will not change coverage

```diff
@@           master   #869   diff @@
====================================
  Files          30     30          
  Lines        1864   1874    +10   
  Methods         0      0          
  Messages        0      0          
  Branches      305    309     +4   
====================================
+ Hits         1864   1874    +10   
  Misses          0      0          
  Partials        0      0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [9499207...901718d](https://codecov.io/gh/falconry/falcon/compare/94992071b1b9c6f828bb827b8be092146a514200...901718d0d5f4618a704f341a9bb9b923187c4b3a?src=pr)
kgriffs: [rebased]
kgriffs: [rebased]
fxfitz: LGTM
jmvrbanac: :+1: 
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
index fc1acd9..47726da 100644
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -60,6 +60,7 @@ def _create_char_encoder(allowed_chars):
 def _create_str_encoder(is_value):
 
     allowed_chars = _UNRESERVED if is_value else _ALL_ALLOWED
+    allowed_chars_plus_percent = allowed_chars + '%'
     encode_char = _create_char_encoder(allowed_chars)
 
     def encoder(uri):
@@ -67,10 +68,32 @@ def _create_str_encoder(is_value):
         if not uri.rstrip(allowed_chars):
             return uri
 
+        if not uri.rstrip(allowed_chars_plus_percent):
+            # NOTE(kgriffs): There's a good chance the string has already
+            # been escaped. Do one more check to increase our certainty.
+            tokens = uri.split('%')
+            for token in tokens[1:]:
+                hex_octet = token[:2]
+
+                if not len(hex_octet) == 2:
+                    break
+
+                if not (hex_octet[0] in _HEX_DIGITS and
+                        hex_octet[1] in _HEX_DIGITS):
+                    break
+            else:
+                # NOTE(kgriffs): All percent-encoded sequences were
+                # valid, so assume that the string has already been
+                # encoded.
+                return uri
+
+            # NOTE(kgriffs): At this point we know there is at least
+            # one unallowed percent character. We are going to assume
+            # that everything should be encoded. If the string is
+            # partially encoded, the caller will need to normalize it
+            # before passing it in here.
+
         # Convert to a byte array if it is not one already
-        #
-        # NOTE(kgriffs): Code coverage disabled since in Py3K the uri
-        # is always a text type, so we get a failure for that tox env.
         if isinstance(uri, six.text_type):
             uri = uri.encode('utf-8')
 

resp.location double-encodes urlencoded strings
I've got a URL which contains a url-encoded URL as a parameter, and when setting resp.location, that results in it being double-encoded:

resp.location = "http://something?redirect_uri=http%3A%2F%2Fsite"

This should result in:
Location: http://something?redirect_uri=http%3A%2F%2Fsite...

But what actually happens is:
Location: http://something?redirect_uri=http%253A%252F%252Fsite

I worked around by raising a HTTPStatus() object, but this doesn't seem like the ideal situation.
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6b5f75d..de32f0a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -177,6 +177,35 @@ class TestFalconUtils(testtools.TestCase):
                     '?limit=3&e%C3%A7ho=true')
         self.assertEqual(uri.encode(url), expected)
 
+    def test_uri_encode_double(self):
+        url = 'http://example.com/v1/fiz bit/messages'
+        expected = 'http://example.com/v1/fiz%20bit/messages'
+        self.assertEqual(uri.encode(uri.encode(url)), expected)
+
+        url = u'http://example.com/v1/fizbit/messages?limit=3&e\u00e7ho=true'
+        expected = ('http://example.com/v1/fizbit/messages'
+                    '?limit=3&e%C3%A7ho=true')
+        self.assertEqual(uri.encode(uri.encode(url)), expected)
+
+        url = 'http://example.com/v1/fiz%bit/mess%ages/%'
+        expected = 'http://example.com/v1/fiz%25bit/mess%25ages/%25'
+        self.assertEqual(uri.encode(uri.encode(url)), expected)
+
+        url = 'http://example.com/%%'
+        expected = 'http://example.com/%25%25'
+        self.assertEqual(uri.encode(uri.encode(url)), expected)
+
+        # NOTE(kgriffs): Specific example cited in GH issue
+        url = 'http://something?redirect_uri=http%3A%2F%2Fsite'
+        self.assertEqual(uri.encode(url), url)
+
+        hex_digits = 'abcdefABCDEF0123456789'
+        for c1 in hex_digits:
+            for c2 in hex_digits:
+                url = 'http://example.com/%' + c1 + c2
+                encoded = uri.encode(uri.encode(url))
+                self.assertEqual(encoded, url)
+
     def test_uri_encode_value(self):
         self.assertEqual(uri.encode_value('abcd'), 'abcd')
         self.assertEqual(uri.encode_value(u'abcd'), u'abcd')

pip install -e .
parse_log_pytest
requirements.txt
nose coverage ddt pyyaml requests testtools pytest
apt-get update apt-get install -y gcc
3.5
tools/test-requires
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==22.2.0
certifi==2021.5.30
charset-normalizer==2.0.12
coverage==6.2
ddt==1.7.2
-e git+https://github.com/falconry/falcon.git@e0f059378b113dcc40e7de7fb16b4f4a104a74f1#egg=falcon
fixtures==4.0.1
idna==3.10
importlib-metadata==4.8.3
iniconfig==1.1.1
nose==1.3.7
packaging==21.3
pbr==6.1.1
pluggy==1.0.0
py==1.11.0
pyparsing==3.1.4
pytest==7.0.1
python-mimeparse==1.6.0
PyYAML==6.0.1
requests==2.27.1
six==1.17.0
testtools==2.6.0
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.26.20
zipp==3.6.0

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
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - 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
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==22.2.0
      - charset-normalizer==2.0.12
      - coverage==6.2
      - ddt==1.7.2
      - fixtures==4.0.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - nose==1.3.7
      - packaging==21.3
      - pbr==6.1.1
      - pluggy==1.0.0
      - py==1.11.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - python-mimeparse==1.6.0
      - pyyaml==6.0.1
      - requests==2.27.1
      - six==1.17.0
      - testtools==2.6.0
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - zipp==3.6.0
prefix: /opt/conda/envs/falcon

tests/test_utils.py::TestFalconUtils::test_uri_encode_double
tests/test_utils.py::TestFalconUtils::test_deprecated_decorator
tests/test_utils.py::TestFalconUtils::test_dt_to_http tests/test_utils.py::TestFalconUtils::test_get_http_status tests/test_utils.py::TestFalconUtils::test_http_date_to_dt tests/test_utils.py::TestFalconUtils::test_http_now tests/test_utils.py::TestFalconUtils::test_pack_query_params_none tests/test_utils.py::TestFalconUtils::test_pack_query_params_one tests/test_utils.py::TestFalconUtils::test_pack_query_params_several tests/test_utils.py::TestFalconUtils::test_parse_host tests/test_utils.py::TestFalconUtils::test_parse_query_string tests/test_utils.py::TestFalconUtils::test_prop_uri_decode_models_stdlib_unquote_plus tests/test_utils.py::TestFalconUtils::test_prop_uri_encode_models_stdlib_quote tests/test_utils.py::TestFalconUtils::test_prop_uri_encode_value_models_stdlib_quote_safe_tilde tests/test_utils.py::TestFalconUtils::test_uri_decode tests/test_utils.py::TestFalconUtils::test_uri_encode tests/test_utils.py::TestFalconUtils::test_uri_encode_value tests/test_utils.py::TestFalconTesting::test_decode_empty_result tests/test_utils.py::TestFalconTesting::test_httpnow_alias_for_backwards_compat tests/test_utils.py::TestFalconTesting::test_none_header_value_in_create_environ tests/test_utils.py::TestFalconTesting::test_path_escape_chars_in_create_environ tests/test_utils.py::TestFalconTestCase::test_cached_text_in_result tests/test_utils.py::TestFalconTestCase::test_path_must_start_with_slash tests/test_utils.py::TestFalconTestCase::test_query_string tests/test_utils.py::TestFalconTestCase::test_query_string_in_path tests/test_utils.py::TestFalconTestCase::test_query_string_no_question tests/test_utils.py::TestFalconTestCase::test_simple_resource_body_json_xor tests/test_utils.py::TestFalconTestCase::test_status tests/test_utils.py::TestFalconTestCase::test_wsgi_iterable_not_closeable tests/test_utils.py::FancyTestCase::test_something
falcon/util/uri.py
falcon/util/uri.py