Coverage for app / backend / src / couchers / notifications / send_raw_push_notification.py: 24%
103 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 11:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 11:04 +0000
1import json
2import logging
3from dataclasses import dataclass
5from sqlalchemy import select
6from sqlalchemy.sql import func
8from couchers.config import config
9from couchers.db import session_scope
10from couchers.metrics import push_notification_counter
11from couchers.models import (
12 PushNotificationDeliveryAttempt,
13 PushNotificationDeliveryOutcome,
14 PushNotificationPlatform,
15 PushNotificationSubscription,
16)
17from couchers.models.notifications import DeviceType
18from couchers.notifications.expo_api import send_expo_push_notification
19from couchers.notifications.web_push_api import send_web_push
20from couchers.proto.internal import jobs_pb2
21from couchers.utils import not_none, now
23logger = logging.getLogger(__name__)
26def is_known_invalid_endpoint(endpoint: str) -> bool:
27 # Edge on Android can generate this bad endpoint URL
28 return endpoint.startswith("https://permanently-removed.invalid/")
31class PushNotificationError(Exception):
32 """Base exception for push notification errors.
34 Transient errors should raise this base class - they will be retried.
35 """
37 def __init__(self, message: str, *, status_code: int | None = None, response: str | None = None):
38 super().__init__(message)
39 self.status_code = status_code
40 self.response = response
43class PermanentSubscriptionFailure(PushNotificationError):
44 """Subscription is permanently broken and should be disabled.
46 Examples: device unregistered, invalid credentials, 404/410 Gone.
47 """
49 pass
52class PermanentMessageFailure(PushNotificationError):
53 """Message cannot be delivered, but the subscription is still valid.
55 Don't disable the subscription, but don't retry this specific message.
56 """
58 pass
61class MessageTooLong(PermanentMessageFailure):
62 """Message exceeds the platform's size limits."""
64 pass
67@dataclass
68class PushDeliveryResult:
69 """Result of a successful push notification delivery."""
71 status_code: int
72 response: str | None = None
73 expo_ticket_id: str | None = None
76def _send_web_push(
77 sub: PushNotificationSubscription, payload: jobs_pb2.SendRawPushNotificationPayloadV2
78) -> PushDeliveryResult:
79 """Send via Web Push API. Raises appropriate exceptions on failure."""
80 if is_known_invalid_endpoint(not_none(sub.endpoint)):
81 raise PermanentSubscriptionFailure("Endpoint is https://permanently-removed.invalid/")
83 data = json.dumps(
84 {
85 "title": payload.title,
86 "body": payload.body,
87 "icon": payload.icon,
88 "url": payload.url,
89 "user_id": payload.user_id,
90 "topic_action": payload.topic_action,
91 "key": payload.key,
92 }
93 ).encode("utf8")
95 if len(data) > 3072:
96 raise MessageTooLong(f"Data too long for web push ({len(data)} bytes, max 3072)")
98 resp = send_web_push(
99 data,
100 not_none(sub.endpoint),
101 not_none(sub.auth_key),
102 not_none(sub.p256dh_key),
103 config["PUSH_NOTIFICATIONS_VAPID_SUBJECT"],
104 config["PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY"],
105 ttl=payload.ttl,
106 )
108 if resp.status_code in [200, 201, 202]:
109 return PushDeliveryResult(status_code=resp.status_code, response=resp.text)
111 if resp.status_code in [404, 410]:
112 raise PermanentSubscriptionFailure(
113 f"Subscription gone (HTTP {resp.status_code})",
114 status_code=resp.status_code,
115 response=resp.text,
116 )
118 # Other errors are transient - will retry
119 raise PushNotificationError(
120 f"Web push failed (HTTP {resp.status_code})",
121 status_code=resp.status_code,
122 response=resp.text,
123 )
126def _send_expo(
127 sub: PushNotificationSubscription, payload: jobs_pb2.SendRawPushNotificationPayloadV2
128) -> PushDeliveryResult:
129 """Send via Expo Push API. Raises appropriate exceptions on failure."""
130 collapse_key = None
131 if payload.topic_action and payload.key:
132 collapse_key = f"{payload.topic_action}_{payload.key}"
134 title: str
135 ios_subtitle: str | None = None
136 if sub.device_type == DeviceType.ios and payload.ios_title:
137 # Prefer the iOS-specific title/subtitle pair if available.
138 title = payload.ios_title
139 ios_subtitle = payload.ios_subtitle
140 else:
141 title = payload.title
143 result = send_expo_push_notification(
144 token=not_none(sub.token),
145 title=title,
146 ios_subtitle=ios_subtitle,
147 body=payload.body,
148 data={
149 "url": payload.url,
150 "topic_action": payload.topic_action,
151 "key": payload.key,
152 },
153 collapse_key=collapse_key,
154 )
156 # Parse the Expo response
157 response_data = {}
158 if isinstance(result.get("data"), list) and len(result.get("data", [])) > 0:
159 response_data = result["data"][0]
160 elif isinstance(result.get("data"), dict):
161 response_data = result["data"]
163 status = response_data.get("status", "unknown")
164 response_str = str(result)
166 if status == "ok":
167 # Extract ticket ID for receipt checking
168 ticket_id = response_data.get("id")
169 return PushDeliveryResult(status_code=200, response=response_str, expo_ticket_id=ticket_id)
171 # Handle error status
172 error_code = response_data.get("details", {}).get("error")
174 if error_code == "MessageTooBig":
175 raise MessageTooLong(
176 f"Expo message too big: {error_code}",
177 status_code=400,
178 response=response_str,
179 )
181 if error_code in {"DeviceNotRegistered", "InvalidCredentials"}:
182 raise PermanentSubscriptionFailure(
183 f"Expo subscription invalid: {error_code}",
184 status_code=400,
185 response=response_str,
186 )
188 # Other errors are transient - will retry
189 raise PushNotificationError(
190 f"Expo push failed: {error_code or status}",
191 status_code=400,
192 response=response_str,
193 )
196def send_raw_push_notification_v2(payload: jobs_pb2.SendRawPushNotificationPayloadV2) -> None:
197 if not config["PUSH_NOTIFICATIONS_ENABLED"]:
198 logger.info("Not sending push notification: push notifications disabled")
199 return
201 with session_scope() as session:
202 sub = session.execute(
203 select(PushNotificationSubscription).where(
204 PushNotificationSubscription.id == payload.push_notification_subscription_id
205 )
206 ).scalar_one()
208 if sub.disabled_at < now():
209 logger.info(f"Skipping push to already-disabled subscription {sub.id}")
210 return
212 try:
213 if sub.platform == PushNotificationPlatform.web_push:
214 result = _send_web_push(sub, payload)
215 elif sub.platform == PushNotificationPlatform.expo:
216 result = _send_expo(sub, payload)
217 else:
218 raise ValueError(f"Unknown platform: {sub.platform}")
220 # Success - receipt will be checked by the batch job check_expo_push_receipts
221 session.add(
222 PushNotificationDeliveryAttempt(
223 push_notification_subscription_id=sub.id,
224 outcome=PushNotificationDeliveryOutcome.success,
225 status_code=result.status_code,
226 response=result.response,
227 expo_ticket_id=result.expo_ticket_id,
228 )
229 )
231 push_notification_counter.labels(platform=sub.platform.name, outcome="success").inc()
232 logger.debug(f"Successfully sent push to sub {sub.id} for user {sub.user_id}")
234 except PermanentSubscriptionFailure as e:
235 logger.info(f"Disabling push sub {sub.id} for user {sub.user_id}: {e}")
236 session.add(
237 PushNotificationDeliveryAttempt(
238 push_notification_subscription_id=sub.id,
239 outcome=PushNotificationDeliveryOutcome.permanent_subscription_failure,
240 status_code=e.status_code,
241 response=e.response,
242 )
243 )
244 sub.disabled_at = func.now()
245 push_notification_counter.labels(platform=sub.platform.name, outcome="permanent_subscription_failure").inc()
247 except PermanentMessageFailure as e:
248 logger.warning(f"Permanent message failure for sub {sub.id}: {e}")
249 session.add(
250 PushNotificationDeliveryAttempt(
251 push_notification_subscription_id=sub.id,
252 outcome=PushNotificationDeliveryOutcome.permanent_message_failure,
253 status_code=e.status_code,
254 response=e.response,
255 )
256 )
257 push_notification_counter.labels(platform=sub.platform.name, outcome="permanent_message_failure").inc()
259 except PushNotificationError as e:
260 # Transient error - log attempt and re-raise to trigger retry
261 logger.warning(f"Transient push failure for sub {sub.id}: {e}")
262 session.add(
263 PushNotificationDeliveryAttempt(
264 push_notification_subscription_id=sub.id,
265 outcome=PushNotificationDeliveryOutcome.transient_failure,
266 status_code=e.status_code,
267 response=e.response,
268 )
269 )
270 push_notification_counter.labels(platform=sub.platform.name, outcome="transient_failure").inc()
271 session.commit()
272 raise