Private
Public Access
WNS (Windows/Edge push) has required an X-WNS-Cache-Policy header since April 2024; the production venv was likely still on the old pywebpush 2.0.x installed when the app was first deployed, which predates the library's fix. Floored the dependency at 2.4.0 (current latest) so the next deploy picks it up. Also logs response body and headers on any non-410/404 push failure, not just body text -- WNS's own 400s carry their actual reason in a header, which the old body-only logging would still have missed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
112 lines
4.4 KiB
Python
112 lines
4.4 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
from pywebpush import WebPushException, webpush
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.models import PushSubscription
|
|
from app.schemas.push import PushSubscriptionCreate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def subscribe(
|
|
db: AsyncSession, user_id: uuid.UUID, data: PushSubscriptionCreate
|
|
) -> PushSubscription:
|
|
# Upsert by endpoint: the same device/browser re-subscribing (e.g. after
|
|
# a key rotation, or logging in as someone else on a shared device)
|
|
# updates the existing row rather than erroring on the unique constraint.
|
|
stmt = (
|
|
pg_insert(PushSubscription)
|
|
.values(
|
|
user_id=user_id,
|
|
endpoint=data.endpoint,
|
|
p256dh_key=data.keys.p256dh,
|
|
auth_key=data.keys.auth,
|
|
)
|
|
.on_conflict_do_update(
|
|
index_elements=[PushSubscription.endpoint],
|
|
set_={
|
|
"user_id": user_id,
|
|
"p256dh_key": data.keys.p256dh,
|
|
"auth_key": data.keys.auth,
|
|
},
|
|
)
|
|
.returning(PushSubscription)
|
|
)
|
|
result = await db.execute(stmt)
|
|
await db.commit()
|
|
return result.scalar_one()
|
|
|
|
|
|
async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> None:
|
|
await db.execute(
|
|
delete(PushSubscription).where(
|
|
PushSubscription.user_id == user_id, PushSubscription.endpoint == endpoint
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
def _send_one(subscription: PushSubscription, payload: dict) -> None:
|
|
webpush(
|
|
subscription_info={
|
|
"endpoint": subscription.endpoint,
|
|
"keys": {"p256dh": subscription.p256dh_key, "auth": subscription.auth_key},
|
|
},
|
|
data=json.dumps(payload),
|
|
vapid_private_key=settings.vapid_private_key,
|
|
vapid_claims={"sub": settings.vapid_subject},
|
|
)
|
|
|
|
|
|
async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict) -> None:
|
|
"""Called (awaited) from the WS handler after broadcasting to connected
|
|
clients, so it never delays delivery to anyone actually online. Runs
|
|
sequentially against the caller's session rather than firing background
|
|
asyncio.create_task()s -- those can easily outlive the request/test event
|
|
loop they were created on, and AsyncSession isn't safe to touch from two
|
|
coroutines concurrently, so a fire-and-forget task per subscription would
|
|
risk exactly that. Each webpush() call itself still runs off the event
|
|
loop via asyncio.to_thread (pywebpush is synchronous)."""
|
|
if not settings.vapid_private_key:
|
|
logger.debug("VAPID keys not configured; skipping push to %s", user_id)
|
|
return
|
|
|
|
result = await db.execute(
|
|
select(PushSubscription).where(PushSubscription.user_id == user_id)
|
|
)
|
|
subscriptions = list(result.scalars().all())
|
|
|
|
for subscription in subscriptions:
|
|
try:
|
|
await asyncio.to_thread(_send_one, subscription, payload)
|
|
except WebPushException as exc:
|
|
status = exc.response.status_code if exc.response is not None else None
|
|
if status in (404, 410):
|
|
# Subscription is gone (browser unsubscribed, expired, etc.)
|
|
await db.execute(
|
|
delete(PushSubscription).where(PushSubscription.id == subscription.id)
|
|
)
|
|
await db.commit()
|
|
else:
|
|
# #56: WNS's own 400s carry the actual reason in a response
|
|
# *header* ("Ttl value conflicts with X-WNS-Cache-Policy"),
|
|
# not the body -- pywebpush's own exception message only
|
|
# ever surfaces the body, so that specific bug still would
|
|
# have needed a full journalctl+DB-dump investigation to
|
|
# diagnose even with a body-only log line. Logging headers
|
|
# too is the difference between "something is broken" and
|
|
# this log line alone being enough next time, for any push
|
|
# provider's failure, not just WNS's.
|
|
response = exc.response
|
|
detail = ""
|
|
if response is not None:
|
|
detail = f" | response: {response.text!r} | headers: {dict(response.headers)!r}"
|
|
logger.warning("Push delivery failed for %s: %s%s", subscription.id, exc, detail)
|