Skip to content
Open
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
31 changes: 28 additions & 3 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:
Copy link
Contributor

@hoodmane hoodmane Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make this into a helper method like this:

try:
    import _cloudflare_compat_flags
except ImportError:
    _cloudflare_compat_flags = object()

def get_compat_flag(flag: string) -> bool:
    return getattr(_cloudflare_compat_flags, flag, false)

try:
import _cloudflare_compat_flags as compat_flags
except Exception:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
except Exception:
except ImportError:

return False

return bool(getattr(compat_flags, "python_request_headers_preserve_commas", False))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the bool part?

Suggested change
return bool(getattr(compat_flags, "python_request_headers_preserve_commas", False))
return 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 @@ -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

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 @@ -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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am curious why are we setting the compat date to a lot future?

# Preserve commas in Python Request headers rather than treating them as separators,
# while still exposing multiple Set-Cookie headers as distinct values.
}
5 changes: 4 additions & 1 deletion src/workerd/server/tests/python/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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()

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
47 changes: 43 additions & 4 deletions src/workerd/server/tests/python/sdk/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading