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

1import json 

2import logging 

3from dataclasses import dataclass 

4 

5from sqlalchemy import select 

6from sqlalchemy.sql import func 

7 

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 

22 

23logger = logging.getLogger(__name__) 

24 

25 

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

29 

30 

31class PushNotificationError(Exception): 

32 """Base exception for push notification errors. 

33 

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

35 """ 

36 

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 

41 

42 

43class PermanentSubscriptionFailure(PushNotificationError): 

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

45 

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

47 """ 

48 

49 pass 

50 

51 

52class PermanentMessageFailure(PushNotificationError): 

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

54 

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

56 """ 

57 

58 pass 

59 

60 

61class MessageTooLong(PermanentMessageFailure): 

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

63 

64 pass 

65 

66 

67@dataclass 

68class PushDeliveryResult: 

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

70 

71 status_code: int 

72 response: str | None = None 

73 expo_ticket_id: str | None = None 

74 

75 

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

82 

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

94 

95 if len(data) > 3072: 

96 raise MessageTooLong(f"Data too long for web push ({len(data)} bytes, max 3072)") 

97 

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 ) 

107 

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

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

110 

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 ) 

117 

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 ) 

124 

125 

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

133 

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 

142 

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 ) 

155 

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

162 

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

164 response_str = str(result) 

165 

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) 

170 

171 # Handle error status 

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

173 

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 ) 

180 

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 ) 

187 

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 ) 

194 

195 

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 

200 

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

207 

208 if sub.disabled_at < now(): 

209 logger.info(f"Skipping push to already-disabled subscription {sub.id}") 

210 return 

211 

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

219 

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 ) 

230 

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

233 

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

246 

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

258 

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