diff --git a/src/pyodide/internal/workers-api/src/workers/_workers.py b/src/pyodide/internal/workers-api/src/workers/_workers.py index 21b97ed55fb..dd8e1a73ddf 100644 --- a/src/pyodide/internal/workers-api/src/workers/_workers.py +++ b/src/pyodide/internal/workers-api/src/workers/_workers.py @@ -318,6 +318,15 @@ def _is_js_instance(val, js_cls_name): return hasattr(val, "constructor") and val.constructor.name == js_cls_name +def _preserve_request_header_commas() -> bool: + try: + import _cloudflare_compat_flags as compat_flags + except Exception: + return False + + return bool(getattr(compat_flags, "python_request_headers_preserve_commas", False)) + + def _to_js_headers(headers: Headers): if isinstance(headers, list): # We should have a list[tuple[str, str]] @@ -775,10 +784,26 @@ def headers(self): import http.client result = http.client.HTTPMessage() + if not _preserve_request_header_commas(): + for key, val in self.js_object.headers: + result[key] = val.strip() + + return result - for key, val in self.js_object.headers: - for subval in val.split(","): - result[key] = subval.strip() + # With the exception of Set-Cookie, duplicate headers can and are combined with a comma + # in the JS Headers API. We do the same when returning the headers to Python. + # + # See https://httpwg.org/specs/rfc9110.html#rfc.section.5.3. + js_headers = self.js_object.headers + set_cookie_headers = js_headers.getSetCookie() + if set_cookie_headers: + for value in set_cookie_headers: + result.add_header("Set-Cookie", value.strip()) + + for key, val in js_headers: + if key.lower() == "set-cookie": + continue + result.add_header(key, val.strip()) return result diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 45d17c67b56..62b2e8f1566 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1335,4 +1335,11 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # checking for null will break. To migrate, either: # 1. Add a null check: if (controller.byobRequest) { ... } # 2. Explicitly set autoAllocateChunkSize when creating the stream + + pythonRequestHeadersPreserveCommas @155 :Bool + $compatEnableFlag("python_request_headers_preserve_commas") + $compatDisableFlag("disable_python_request_headers_preserve_commas") + $compatEnableDate("2026-02-04"); + # Preserve commas in Python Request headers rather than treating them as separators, + # while still exposing multiple Set-Cookie headers as distinct values. } diff --git a/src/workerd/server/tests/python/BUILD.bazel b/src/workerd/server/tests/python/BUILD.bazel index efdc7fbf9bb..d1d3805fad9 100644 --- a/src/workerd/server/tests/python/BUILD.bazel +++ b/src/workerd/server/tests/python/BUILD.bazel @@ -32,7 +32,10 @@ py_wd_test("random") py_wd_test("subdirectory") -py_wd_test("sdk") +py_wd_test( + "sdk", + compat_date = "2026-01-01", +) gen_rust_import_tests() diff --git a/src/workerd/server/tests/python/sdk/sdk.wd-test b/src/workerd/server/tests/python/sdk/sdk.wd-test index 6f817238b1b..986e9aafa71 100644 --- a/src/workerd/server/tests/python/sdk/sdk.wd-test +++ b/src/workerd/server/tests/python/sdk/sdk.wd-test @@ -7,14 +7,14 @@ const python :Workerd.Worker = ( bindings = [ ( name = "SELF", service = "python-sdk" ), ], - compatibilityFlags = [%PYTHON_FEATURE_FLAGS, "service_binding_extra_handlers", "rpc", "python_no_global_handlers", "fetch_standard_url", "formdata_parser_supports_files"], + compatibilityFlags = [%PYTHON_FEATURE_FLAGS, "service_binding_extra_handlers", "rpc", "python_no_global_handlers", "fetch_standard_url", "formdata_parser_supports_files", "python_request_headers_preserve_commas"], ); const server :Workerd.Worker = ( modules = [ (name = "server.py", pythonModule = embed "server.py") ], - compatibilityFlags = [%PYTHON_FEATURE_FLAGS, "rpc", "python_no_global_handlers", "fetch_standard_url", "formdata_parser_supports_files"], + compatibilityFlags = [%PYTHON_FEATURE_FLAGS, "rpc", "python_no_global_handlers", "fetch_standard_url", "formdata_parser_supports_files", "python_request_headers_preserve_commas"], ); # We need this proxy so that internal requests made to fetch packages for Python Workers are sent diff --git a/src/workerd/server/tests/python/sdk/worker.py b/src/workerd/server/tests/python/sdk/worker.py index 84747e956a6..28523a91a36 100644 --- a/src/workerd/server/tests/python/sdk/worker.py +++ b/src/workerd/server/tests/python/sdk/worker.py @@ -143,8 +143,7 @@ def fetch_check(url, opts): # # Try to grab headers which should contain a duplicated header. headers = request.headers.get_all("X-Custom-Header") - assert "some_value" in headers - assert "some_other_value" in headers + assert "some_value, some_other_value" in headers return Response("success") else: resp = await fetch("https://example.com/sub") @@ -414,8 +413,48 @@ async def request_unit_tests(env): req_with_dup_headers = Request("http://example.com", headers=js_headers) assert req_with_dup_headers.url == "http://example.com/" encoding = req_with_dup_headers.headers.get_all("Accept-encoding") - assert "deflate" in encoding - assert "gzip" in encoding + assert encoding == ["deflate, gzip"] + + # Verify that header values containing commas are preserved. + js_headers = js.Headers.new() + js_headers.set("User-Agent", "Example, Agent/1.0") + req_with_user_agent = Request("http://example.com", headers=js_headers) + assert req_with_user_agent.headers.get("User-Agent") == "Example, Agent/1.0" + assert req_with_user_agent.headers.get_all("User-Agent") == ["Example, Agent/1.0"] + + # Verify that header values with commas are preserved when using Python dict. + req_dict_comma = Request( + "http://example.com", + headers={ + "User-Agent": "Python, Client/2.0", + "Accept": "text/html, application/json", + }, + ) + assert req_dict_comma.headers.get("User-Agent") == "Python, Client/2.0" + assert "text/html" in req_dict_comma.headers.get("Accept") + assert "application/json" in req_dict_comma.headers.get("Accept") + + # Verify that Set-Cookie headers are preserved as distinct values. + js_headers = js.Headers.new() + js_headers.append("Set-Cookie", "a=b, c=d") + js_headers.append("Set-Cookie", "e=f") + req_with_set_cookie = Request("http://example.com", headers=js_headers) + assert req_with_set_cookie.headers.get_all("Set-Cookie") == ["a=b, c=d", "e=f"] + + # Verify that Set-Cookie headers work with Python list of tuples. + req_tuple_cookies = Request( + "http://example.com", + headers=[ + ("Set-Cookie", "session=abc123"), + ("Set-Cookie", "token=xyz789"), + ("X-Custom", "value"), + ], + ) + assert req_tuple_cookies.headers.get_all("Set-Cookie") == [ + "session=abc123", + "token=xyz789", + ] + assert req_tuple_cookies.headers.get("X-Custom") == "value" # Verify that we can get a Blob. req_for_blob = Request("http://example.com", body="foobar", method="POST")