Actually fix WNS push, and stop the notification toggle hanging forever (#56)

The pywebpush version bump alone didn't fix WNS: even the latest
release (2.4.0) has no WNS-specific header handling in its own
source, confirmed by inspecting the installed package directly.
Adds the required X-WNS-Cache-Policy header ourselves via
webpush()'s own headers= param, gated to *.notify.windows.com
endpoints.

Also: subscribeToPush()'s permission request and service-worker-ready
wait had no timeout, so a browser that never settles either (seen
live on a fresh Windows/Edge install -- greyed out, no prompt, no
error) left the toggle stuck forever with no feedback. Both now time
out after 20s with an actionable message instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 21:16:24 -06:00
co-authored by Claude Sonnet 5
parent ad745c734e
commit 3e5f842df3
4 changed files with 121 additions and 9 deletions
+18
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import logging
import uuid
from urllib.parse import urlparse
from pywebpush import WebPushException, webpush
from sqlalchemy import delete, select
@@ -53,7 +54,23 @@ async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> No
await db.commit()
# #56 correction: bumping to pywebpush's latest release (2.4.0) turned out
# not to actually fix WNS -- checked the installed package's own source
# directly and it has no WNS-specific code anywhere; the upstream
# discussion (web-push-libs/pywebpush#162) apparently never shipped.
# Worked around here instead, using the `headers` param webpush() already
# exposes for exactly this: WNS (Windows/Edge push,
# *.notify.windows.com) has required this header since April 2024, or it
# 400s with no useful body -- "cache" for a non-zero TTL, "no-cache" for
# zero (this app never sets a TTL, so always the latter).
def _is_wns_endpoint(endpoint: str) -> bool:
return urlparse(endpoint).hostname is not None and urlparse(endpoint).hostname.endswith(
"notify.windows.com"
)
def _send_one(subscription: PushSubscription, payload: dict) -> None:
extra_headers = {"X-WNS-Cache-Policy": "no-cache"} if _is_wns_endpoint(subscription.endpoint) else None
webpush(
subscription_info={
"endpoint": subscription.endpoint,
@@ -62,6 +79,7 @@ def _send_one(subscription: PushSubscription, payload: dict) -> None:
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
headers=extra_headers,
)
+7 -7
View File
@@ -15,13 +15,13 @@ dependencies = [
"pydantic-settings>=2.6",
"argon2-cffi>=23.1",
"itsdangerous>=2.2",
# #56: >=2.0 let the production venv sit on an old 2.0.x that predates
# the fix for web-push-libs/pywebpush#162 -- WNS (Windows/Edge push)
# has required an X-WNS-Cache-Policy header since April 2024, and an
# old pywebpush that never sends it gets a bodyless 400 on every send.
# Floored at the actual latest release (merged/shipped 2026-01 through
# 2026-08) rather than pinning to whichever exact point release first
# included the fix, since that wasn't independently confirmed.
# #56: >=2.0 let the production venv sit on an old 2.0.x with no real
# downside to bumping the floor -- worth keeping current regardless.
# Doesn't by itself fix WNS (Windows/Edge push): despite
# web-push-libs/pywebpush#162's discussion, even the latest release
# (2.4.0) has no WNS-specific header handling in its own source. The
# actual fix is app/services/push_service.py adding the required
# X-WNS-Cache-Policy header itself via webpush()'s `headers` param.
"pywebpush>=2.4.0",
"redis>=5.0",
"httpx>=0.27",
+58
View File
@@ -272,3 +272,61 @@ def test_non_gone_push_failure_logs_response_detail_and_keeps_subscription(ws_cl
assert len(calls) == 1
assert "Bad Request" in calls[0]
assert "Ttl value conflicts with X-WNS-Cache-Policy" in calls[0]
def test_wns_endpoint_gets_cache_policy_header(ws_client, monkeypatch):
calls = []
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post(
"/api/push/subscribe",
json={
"endpoint": f"https://wns2-by3p.notify.windows.com/w/{_unique('bob')}",
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
},
)
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
assert ws.receive_json()["type"] == "message"
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
assert len(calls) == 1
assert calls[0]["headers"] == {"X-WNS-Cache-Policy": "no-cache"}
def test_non_wns_endpoint_gets_no_extra_headers(ws_client, monkeypatch):
calls = []
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(f"/api/rooms/{room['id']}/join")
ws_client.post("/api/push/subscribe", json=_subscription_payload(_unique("bob")))
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
assert ws.receive_json()["type"] == "message"
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
assert len(calls) == 1
assert calls[0]["headers"] is None