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

1import json 

2import logging 

3from dataclasses import dataclass 

4 

5import sentry_sdk 

6from sqlalchemy import select 

7from sqlalchemy.sql import func 

8 

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 

23 

24logger = logging.getLogger(__name__) 

25 

26 

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/") 

30 

31 

32class PushNotificationError(Exception): 

33 """Base exception for push notification errors. 

34 

35 Transient errors should raise this base class - they will be retried. 

36 """ 

37 

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 

50 

51 

52class PermanentSubscriptionFailure(PushNotificationError): 

53 """Subscription is permanently broken and should be disabled. 

54 

55 Examples: device unregistered, invalid credentials, 404/410 Gone. 

56 """ 

57 

58 pass 

59 

60 

61class PermanentMessageFailure(PushNotificationError): 

62 """Message cannot be delivered, but the subscription is still valid. 

63 

64 Don't disable the subscription, but don't retry this specific message. 

65 """ 

66 

67 pass 

68 

69 

70class MessageTooLong(PermanentMessageFailure): 

71 """Message exceeds the platform's size limits.""" 

72 

73 pass 

74 

75 

76@dataclass 

77class PushDeliveryResult: 

78 """Result of a successful push notification delivery.""" 

79 

80 status_code: int 

81 response: str | None = None 

82 expo_ticket_id: str | None = None 

83 

84 

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/") 

91 

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") 

103 

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)") 

106 

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 ) 

116 

117 if resp.status_code in [200, 201, 202]: 

118 return PushDeliveryResult(status_code=resp.status_code, response=resp.text) 

119 

120 headers = debug_response_headers(resp) 

121 

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 ) 

129 

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 ) 

138 

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 ) 

146 

147 

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}" 

155 

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 

164 

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 ) 

177 

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"] 

184 

185 status = response_data.get("status", "unknown") 

186 response_str = str(result) 

187 

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) 

192 

193 # Handle error status 

194 error_code = response_data.get("details", {}).get("error") 

195 

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 ) 

202 

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 ) 

209 

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 ) 

216 

217 

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 

222 

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() 

229 

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 

233 

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}") 

241 

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 ) 

252 

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}") 

255 

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() 

268 

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() 

293 

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