Files
ds-chat/backend/app/services/push_service.py
T
ksmithandClaude Sonnet 5 ad745c734e Fix WNS push 400s and improve push failure logging (#56)
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>
2026-08-28 21:04:04 -06:00

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)