From 2be488a72db3767184813ffd5bdffcb902e48083 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:27:11 +0100 Subject: [PATCH 1/5] fix(workers-http): preserve raw header values --- src/pyodide/internal/workers-api/src/workers/_workers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pyodide/internal/workers-api/src/workers/_workers.py b/src/pyodide/internal/workers-api/src/workers/_workers.py index 21b97ed55fb..729d894a37f 100644 --- a/src/pyodide/internal/workers-api/src/workers/_workers.py +++ b/src/pyodide/internal/workers-api/src/workers/_workers.py @@ -777,8 +777,7 @@ def headers(self): result = http.client.HTTPMessage() for key, val in self.js_object.headers: - for subval in val.split(","): - result[key] = subval.strip() + result[key] = subval.strip() return result From ce5bbb78355976dc08ff11a9f6c50320b2d57638 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:30:10 +0100 Subject: [PATCH 2/5] Update _workers.py --- src/pyodide/internal/workers-api/src/workers/_workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyodide/internal/workers-api/src/workers/_workers.py b/src/pyodide/internal/workers-api/src/workers/_workers.py index 729d894a37f..ff4d9eb537a 100644 --- a/src/pyodide/internal/workers-api/src/workers/_workers.py +++ b/src/pyodide/internal/workers-api/src/workers/_workers.py @@ -777,7 +777,7 @@ def headers(self): result = http.client.HTTPMessage() for key, val in self.js_object.headers: - result[key] = subval.strip() + result[key] = val.strip() return result From a4a665119c76f0319f9469450fb7b83a2cb6bbfa Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:29:45 +0000 Subject: [PATCH 3/5] fix(python): preserve commas in Request headers with compat flag --- .../workers-api/src/workers/_workers.py | 39 +++++++++++++++++++ src/workerd/io/compatibility-date.capnp | 7 ++++ .../server/tests/python/sdk/sdk.wd-test | 4 +- src/workerd/server/tests/python/sdk/worker.py | 19 ++++++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/pyodide/internal/workers-api/src/workers/_workers.py b/src/pyodide/internal/workers-api/src/workers/_workers.py index ff4d9eb537a..4ba677009f9 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]] @@ -774,6 +783,36 @@ def headers(self): # TODO(later): when dedicated snapshots are default we can move this import to the top-level. import http.client + if _preserve_request_header_commas(): + from email.message import Message + from email.policy import compat32 + + result = Message(policy=compat32) + js_headers = self.js_object.headers + + set_cookie_values = None + if hasattr(js_headers, "getSetCookie"): + try: + set_cookie_values = js_headers.getSetCookie() + except Exception: + set_cookie_values = None + elif hasattr(js_headers, "getAll"): + try: + set_cookie_values = js_headers.getAll("Set-Cookie") + except Exception: + set_cookie_values = None + + if set_cookie_values: + for value in set_cookie_values: + 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 + result = http.client.HTTPMessage() for key, val in self.js_object.headers: diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 45d17c67b56..cfb18fc4772 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1273,6 +1273,13 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { $impliedByAfterDate(name = "pedanticWpt", date = "2026-01-13"); # Instructs the readAllText method in streams to strip the leading UTF8 BOM if present. + pythonRequestHeadersPreserveCommas @152 :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. + allowIrrevocableStubStorage @151 :Bool $compatEnableFlag("allow_irrevocable_stub_storage") $experimental; 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..de02acfbb3c 100644 --- a/src/workerd/server/tests/python/sdk/worker.py +++ b/src/workerd/server/tests/python/sdk/worker.py @@ -414,8 +414,23 @@ 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 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 we can get a Blob. req_for_blob = Request("http://example.com", body="foobar", method="POST") From 3b930d8868b2aa043c86dd3ae5cedfb540866776 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:30:03 +0000 Subject: [PATCH 4/5] fix(tests): improve header handling in Request tests to preserve commas and distinct Set-Cookie values --- src/workerd/server/tests/python/sdk/worker.py | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/workerd/server/tests/python/sdk/worker.py b/src/workerd/server/tests/python/sdk/worker.py index de02acfbb3c..6dfec28d861 100644 --- a/src/workerd/server/tests/python/sdk/worker.py +++ b/src/workerd/server/tests/python/sdk/worker.py @@ -188,9 +188,9 @@ async def test(self): async def can_return_custom_fetch_response(env): - assert isinstance(env, JsProxy), ( - "Expecting the env for these tests not to be wrapped" - ) + assert isinstance( + env, JsProxy + ), "Expecting the env for these tests not to be wrapped" response = await env.SELF.fetch( "http://example.com/", ) @@ -421,9 +421,19 @@ async def request_unit_tests(env): 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" - ] + 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() @@ -432,6 +442,20 @@ async def request_unit_tests(env): 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") blob = await req_for_blob.blob() @@ -563,9 +587,9 @@ async def response_buffer_source_unit_tests(env): ) from exc buffer = await response.buffer() - assert int(buffer.byteLength) == expected_length, ( - f"Response buffer length mismatch for {type_name}" - ) + assert ( + int(buffer.byteLength) == expected_length + ), f"Response buffer length mismatch for {type_name}" async def can_fetch_python_request(): From 78b49ca38bddf34785022da614c26a19638ca582 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Fri, 16 Jan 2026 16:11:50 +0000 Subject: [PATCH 5/5] Fixes to Python header handling and tests --- .../workers-api/src/workers/_workers.py | 49 +++++++------------ src/workerd/io/compatibility-date.capnp | 14 +++--- src/workerd/server/tests/python/BUILD.bazel | 5 +- src/workerd/server/tests/python/sdk/worker.py | 18 +++---- 4 files changed, 38 insertions(+), 48 deletions(-) diff --git a/src/pyodide/internal/workers-api/src/workers/_workers.py b/src/pyodide/internal/workers-api/src/workers/_workers.py index 4ba677009f9..dd8e1a73ddf 100644 --- a/src/pyodide/internal/workers-api/src/workers/_workers.py +++ b/src/pyodide/internal/workers-api/src/workers/_workers.py @@ -783,40 +783,27 @@ def headers(self): # TODO(later): when dedicated snapshots are default we can move this import to the top-level. import http.client - if _preserve_request_header_commas(): - from email.message import Message - from email.policy import compat32 - - result = Message(policy=compat32) - js_headers = self.js_object.headers - - set_cookie_values = None - if hasattr(js_headers, "getSetCookie"): - try: - set_cookie_values = js_headers.getSetCookie() - except Exception: - set_cookie_values = None - elif hasattr(js_headers, "getAll"): - try: - set_cookie_values = js_headers.getAll("Set-Cookie") - except Exception: - set_cookie_values = None - - if set_cookie_values: - for value in set_cookie_values: - 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()) + result = http.client.HTTPMessage() + if not _preserve_request_header_commas(): + for key, val in self.js_object.headers: + result[key] = val.strip() return result - result = http.client.HTTPMessage() - - for key, val in self.js_object.headers: - result[key] = val.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 cfb18fc4772..62b2e8f1566 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1273,13 +1273,6 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { $impliedByAfterDate(name = "pedanticWpt", date = "2026-01-13"); # Instructs the readAllText method in streams to strip the leading UTF8 BOM if present. - pythonRequestHeadersPreserveCommas @152 :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. - allowIrrevocableStubStorage @151 :Bool $compatEnableFlag("allow_irrevocable_stub_storage") $experimental; @@ -1342,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/worker.py b/src/workerd/server/tests/python/sdk/worker.py index 6dfec28d861..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") @@ -188,9 +187,9 @@ async def test(self): async def can_return_custom_fetch_response(env): - assert isinstance( - env, JsProxy - ), "Expecting the env for these tests not to be wrapped" + assert isinstance(env, JsProxy), ( + "Expecting the env for these tests not to be wrapped" + ) response = await env.SELF.fetch( "http://example.com/", ) @@ -452,7 +451,8 @@ async def request_unit_tests(env): ], ) assert req_tuple_cookies.headers.get_all("Set-Cookie") == [ - "session=abc123, token=xyz789" + "session=abc123", + "token=xyz789", ] assert req_tuple_cookies.headers.get("X-Custom") == "value" @@ -587,9 +587,9 @@ async def response_buffer_source_unit_tests(env): ) from exc buffer = await response.buffer() - assert ( - int(buffer.byteLength) == expected_length - ), f"Response buffer length mismatch for {type_name}" + assert int(buffer.byteLength) == expected_length, ( + f"Response buffer length mismatch for {type_name}" + ) async def can_fetch_python_request():