Coverage for app/backend/src/couchers/notifications/send_raw_push_notification.py: 62%
112 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import json
2import logging
3from dataclasses import dataclass
5import sentry_sdk
6from sqlalchemy import select
7from sqlalchemy.sql import func
9from couchers.config import config
10from couchers.db import session_scope
11from couchers.metrics import push_notification_counter
12from couchers.models import (
13 PushNotificationDeliveryAttempt,
14 PushNotificationDeliveryOutcome,
15 PushNotificationPlatform,
16 PushNotificationSubscription,
17)
18from couchers.models.notifications import DeviceType
19from couchers.notifications.expo_api import send_expo_push_notification
20from couchers.notifications.web_push_api import debug_response_headers, send_web_push
21from couchers.proto.internal import jobs_pb2
22from couchers.utils import not_none, now
24logger = logging.getLogger(__name__)
27def is_known_invalid_endpoint(endpoint: str) -> bool:
28 # Edge on Android can generate this bad endpoint URL
29 return endpoint.startswith("https://permanently-removed.invalid/")
32class PushNotificationError(Exception):
33 """Base exception for push notification errors.
35 Transient errors should raise this base class - they will be retried.
36 """
38 def __init__(
39 self,
40 message: str,
41 *,
42 status_code: int | None = None,
43 response: str | None = None,
44 response_headers: dict[str, str] | None = None,
45 ):
46 super().__init__(message)
47 self.status_code = status_code
48 self.response = response
49 self.response_headers = response_headers
52class PermanentSubscriptionFailure(PushNotificationError):
53 """Subscription is permanently broken and should be disabled.
55 Examples: device unregistered, invalid credentials, 404/410 Gone.
56 """
58 pass
61class PermanentMessageFailure(PushNotificationError):
62 """Message cannot be delivered, but the subscription is still valid.
64 Don't disable the subscription, but don't retry this specific message.
65 """
67 pass
70class MessageTooLong(PermanentMessageFailure):
71 """Message exceeds the platform's size limits."""
73 pass
76@dataclass
77class PushDeliveryResult:
78 """Result of a successful push notification delivery."""
80 status_code: int
81 response: str | None = None
82 expo_ticket_id: str | None = None
85def _send_web_push(
86 sub: PushNotificationSubscription, payload: jobs_pb2.SendRawPushNotificationPayloadV2
87) -> PushDeliveryResult:
88 """Send via Web Push API. Raises appropriate exceptions on failure."""
89 if is_known_invalid_endpoint(not_none(sub.endpoint)): 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 raise PermanentSubscriptionFailure("Endpoint is https://permanently-removed.invalid/")
92 data = json.dumps(
93 {
94 "title": payload.title,
95 "body": payload.body,
96 "icon": payload.icon,
97 "url": payload.url,
98 "user_id": payload.user_id,
99 "topic_action": payload.topic_action,
100 "key": payload.key,
101 }
102 ).encode("utf8")
104 if len(data) > 3072: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 raise MessageTooLong(f"Data too long for web push ({len(data)} bytes, max 3072)")
107 resp = send_web_push(
108 data,
109 not_none(sub.endpoint),
110 not_none(sub.auth_key),
111 not_none(sub.p256dh_key),
112 config.PUSH_NOTIFICATIONS_VAPID_SUBJECT,
113 config.PUSH_NOTIFICATIONS_VAPID_PRIVATE_KEY,
114 ttl=payload.ttl,
115 )
117 if resp.status_code in [200, 201, 202]:
118 return PushDeliveryResult(status_code=resp.status_code, response=resp.text)
120 headers = debug_response_headers(resp)
122 if resp.status_code in [404, 410]:
123 raise PermanentSubscriptionFailure(
124 f"Subscription gone (HTTP {resp.status_code})",
125 status_code=resp.status_code,
126 response=resp.text,
127 response_headers=headers,
128 )
130 if resp.status_code == 400:
131 # the push service rejected the request itself, so re-sending it won't help
132 raise PermanentMessageFailure(
133 f"Web push rejected as a bad request (HTTP {resp.status_code}): {headers}",
134 status_code=resp.status_code,
135 response=resp.text,
136 response_headers=headers,
137 )
139 # Other errors are transient - will retry
140 raise PushNotificationError(
141 f"Web push failed (HTTP {resp.status_code}): {headers}",
142 status_code=resp.status_code,
143 response=resp.text,
144 response_headers=headers,
145 )
148def _send_expo(
149 sub: PushNotificationSubscription, payload: jobs_pb2.SendRawPushNotificationPayloadV2
150) -> PushDeliveryResult:
151 """Send via Expo Push API. Raises appropriate exceptions on failure."""
152 collapse_key = None
153 if payload.topic_action and payload.key:
154 collapse_key = f"{payload.topic_action}_{payload.key}"
156 title: str
157 ios_subtitle: str | None = None
158 if sub.device_type == DeviceType.ios and payload.ios_title:
159 # Prefer the iOS-specific title/subtitle pair if available.
160 title = payload.ios_title
161 ios_subtitle = payload.ios_subtitle
162 else:
163 title = payload.title
165 result = send_expo_push_notification(
166 token=not_none(sub.token),
167 title=title,
168 ios_subtitle=ios_subtitle,
169 body=payload.body,
170 data={
171 "url": payload.url,
172 "topic_action": payload.topic_action,
173 "key": payload.key,
174 },
175 collapse_key=collapse_key,
176 )
178 # Parse the Expo response
179 response_data = {}
180 if isinstance(result.get("data"), list) and len(result.get("data", [])) > 0:
181 response_data = result["data"][0]
182 elif isinstance(result.get("data"), dict):
183 response_data = result["data"]
185 status = response_data.get("status", "unknown")
186 response_str = str(result)
188 if status == "ok":
189 # Extract ticket ID for receipt checking
190 ticket_id = response_data.get("id")
191 return PushDeliveryResult(status_code=200, response=response_str, expo_ticket_id=ticket_id)
193 # Handle error status
194 error_code = response_data.get("details", {}).get("error")
196 if error_code == "MessageTooBig":
197 raise MessageTooLong(
198 f"Expo message too big: {error_code}",
199 status_code=400,
200 response=response_str,
201 )
203 if error_code in {"DeviceNotRegistered", "InvalidCredentials"}:
204 raise PermanentSubscriptionFailure(
205 f"Expo subscription invalid: {error_code}",
206 status_code=400,
207 response=response_str,
208 )
210 # Other errors are transient - will retry
211 raise PushNotificationError(
212 f"Expo push failed: {error_code or status}",
213 status_code=400,
214 response=response_str,
215 )
218def send_raw_push_notification_v2(payload: jobs_pb2.SendRawPushNotificationPayloadV2) -> None:
219 if not config.PUSH_NOTIFICATIONS_ENABLED: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 logger.info("Not sending push notification: push notifications disabled")
221 return
223 with session_scope() as session:
224 sub = session.execute(
225 select(PushNotificationSubscription).where(
226 PushNotificationSubscription.id == payload.push_notification_subscription_id
227 )
228 ).scalar_one()
230 if sub.disabled_at < now(): 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 logger.info(f"Skipping push to already-disabled subscription {sub.id}")
232 return
234 try:
235 if sub.platform == PushNotificationPlatform.web_push: 235 ↛ 237line 235 didn't jump to line 237 because the condition on line 235 was always true
236 result = _send_web_push(sub, payload)
237 elif sub.platform == PushNotificationPlatform.expo:
238 result = _send_expo(sub, payload)
239 else:
240 raise ValueError(f"Unknown platform: {sub.platform}")
242 # Success - receipt will be checked by the batch job check_expo_push_receipts
243 session.add(
244 PushNotificationDeliveryAttempt(
245 push_notification_subscription_id=sub.id,
246 outcome=PushNotificationDeliveryOutcome.success,
247 status_code=result.status_code,
248 response=result.response,
249 expo_ticket_id=result.expo_ticket_id,
250 )
251 )
253 push_notification_counter.labels(platform=sub.platform.name, outcome="success").inc()
254 logger.debug(f"Successfully sent push to sub {sub.id} for user {sub.user_id}")
256 except PermanentSubscriptionFailure as e:
257 logger.info(f"Disabling push sub {sub.id} for user {sub.user_id}: {e}")
258 session.add(
259 PushNotificationDeliveryAttempt(
260 push_notification_subscription_id=sub.id,
261 outcome=PushNotificationDeliveryOutcome.permanent_subscription_failure,
262 status_code=e.status_code,
263 response=e.response,
264 )
265 )
266 sub.disabled_at = func.now()
267 push_notification_counter.labels(platform=sub.platform.name, outcome="permanent_subscription_failure").inc()
269 except PermanentMessageFailure as e:
270 logger.warning(f"Permanent message failure for sub {sub.id}: {e}")
271 # this notification is dropped and never retried, so it won't reach Sentry any other way
272 with sentry_sdk.new_scope() as scope:
273 scope.set_tag("context", "push")
274 scope.set_context(
275 "push_response",
276 {
277 "platform": sub.platform.name,
278 "status_code": e.status_code,
279 "response": e.response,
280 "response_headers": e.response_headers,
281 },
282 )
283 sentry_sdk.capture_exception(e)
284 session.add(
285 PushNotificationDeliveryAttempt(
286 push_notification_subscription_id=sub.id,
287 outcome=PushNotificationDeliveryOutcome.permanent_message_failure,
288 status_code=e.status_code,
289 response=e.response,
290 )
291 )
292 push_notification_counter.labels(platform=sub.platform.name, outcome="permanent_message_failure").inc()
294 except PushNotificationError as e:
295 # Transient error - log attempt and re-raise to trigger retry
296 logger.warning(f"Transient push failure for sub {sub.id}: {e}")
297 session.add(
298 PushNotificationDeliveryAttempt(
299 push_notification_subscription_id=sub.id,
300 outcome=PushNotificationDeliveryOutcome.transient_failure,
301 status_code=e.status_code,
302 response=e.response,
303 )
304 )
305 push_notification_counter.labels(platform=sub.platform.name, outcome="transient_failure").inc()
306 session.commit()
307 raise