Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/pyodide/internal/workers-api/src/workers/_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -774,11 +783,40 @@ 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:
for subval in val.split(","):
result[key] = subval.strip()
result[key] = val.strip()

return result

Expand Down
7 changes: 7 additions & 0 deletions src/workerd/io/compatibility-date.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/workerd/server/tests/python/sdk/sdk.wd-test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 47 additions & 8 deletions src/workerd/server/tests/python/sdk/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
)
Expand Down Expand Up @@ -414,8 +414,47 @@ 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")
Expand Down Expand Up @@ -548,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():
Expand Down
Loading