Skip to main content

autoendpoint/routers/
webpush.rs

1use async_trait::async_trait;
2#[cfg(feature = "reliable_report")]
3use autopush_common::reliability::PushReliability;
4use cadence::{Counted, StatsdClient, Timed};
5use reqwest::{Response, StatusCode};
6use serde_json::Value;
7use std::collections::{HashMap, hash_map::RandomState};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::time::Instant;
11use url::Url;
12use uuid::Uuid;
13
14use crate::error::{ApiError, ApiErrorKind, ApiResult};
15use crate::extractors::{notification::Notification, router_data_input::RouterDataInput};
16use crate::headers::vapid::VapidHeaderWithKey;
17use crate::routers::{Router, RouterError, RouterResponse};
18
19use autopush_common::db::{User, client::DbClient};
20use autopush_common::metric_name::MetricName;
21use autopush_common::metrics::StatsdClientExt;
22
23/// The router for desktop user agents.
24///
25/// These agents are connected via an Autopush connection server. The correct
26/// server is located via the database routing table. If the server is busy or
27/// not available, the notification is stored in the database.
28pub struct WebPushRouter {
29    pub db: Box<dyn DbClient>,
30    pub metrics: Arc<StatsdClient>,
31    pub http: reqwest::Client,
32    pub endpoint_url: Url,
33    pub in_flight_requests: Arc<AtomicUsize>,
34    #[cfg(feature = "reliable_report")]
35    pub reliability: Arc<PushReliability>,
36}
37
38#[async_trait(?Send)]
39impl Router for WebPushRouter {
40    fn register(
41        &self,
42        _router_input: &RouterDataInput,
43        _app_id: &str,
44    ) -> Result<HashMap<String, Value, RandomState>, RouterError> {
45        // WebPush registration happens through the connection server
46        Ok(HashMap::new())
47    }
48
49    async fn route_notification(&self, notification: Notification) -> ApiResult<RouterResponse> {
50        let route_start = Instant::now();
51        let result = self.route_notification_inner(notification).await;
52        self.metrics
53            .time_with_tags(
54                MetricName::NotificationRouteTime.as_ref(),
55                route_start.elapsed().as_millis() as u64,
56            )
57            .with_tag("outcome", if result.is_ok() { "ok" } else { "error" })
58            .send();
59        result
60    }
61}
62
63impl WebPushRouter {
64    async fn route_notification_inner(
65        &self,
66        mut notification: Notification,
67    ) -> ApiResult<RouterResponse> {
68        // The notification contains the original subscription information.
69        // Extract user fields upfront to avoid borrow conflicts with
70        // record_reliability's &mut self requirement.
71        let notif_user = &notification.subscription.user;
72        let uaid = notif_user.uaid;
73        let node_id = notif_user.node_id.clone();
74        debug!(
75            "✉ Routing WebPush notification to UAID {} :: {:?}",
76            uaid, notification.subscription.reliability_id,
77        );
78        trace!("✉ Notification = {:?}", notification);
79
80        // Check if there is a node connected to the client
81        if let Some(node_id) = node_id {
82            trace!(
83                "✉ User has a node ID, sending notification to node: {}",
84                &node_id
85            );
86
87            #[cfg(feature = "reliable_report")]
88            let revert_state = notification.reliable_state;
89            #[cfg(feature = "reliable_report")]
90            notification
91                .record_reliability(
92                    &self.reliability,
93                    autopush_common::reliability::ReliabilityState::IntTransmitted,
94                )
95                .await;
96            let send_start = Instant::now();
97            match self.send_notification(&notification, &node_id).await {
98                Ok(response) => {
99                    let elapsed = send_start.elapsed().as_millis() as u64;
100                    let status = response.status().as_u16();
101                    self.metrics
102                        .time_with_tags(MetricName::DirectDeliveryTime.as_ref(), elapsed)
103                        .with_tag("status", &status.to_string())
104                        .send();
105                    self.metrics
106                        .incr_with_tags(MetricName::DirectDeliveryStatus)
107                        .with_tag("status", &status.to_string())
108                        .send();
109                    // The node might be busy, make sure it accepted the notification
110                    if status == 200 {
111                        // The node has received the notification
112                        trace!("✉ Node received notification");
113                        return Ok(self.make_delivered_response(&notification));
114                    }
115                    trace!(
116                        "✉ Node did not receive the notification, response = {:?}",
117                        response
118                    );
119                }
120                Err(error) => {
121                    let elapsed = send_start.elapsed().as_millis() as u64;
122                    let status_tag = if let ApiErrorKind::ReqwestError(error) = &error.kind {
123                        if error.is_timeout() {
124                            self.metrics.incr(MetricName::ErrorNodeTimeout)?;
125                            "timeout"
126                        } else if error.is_connect() {
127                            self.metrics.incr(MetricName::ErrorNodeConnect)?;
128                            "connect_error"
129                        } else {
130                            "error"
131                        }
132                    } else {
133                        "error"
134                    };
135                    self.metrics
136                        .time_with_tags(MetricName::DirectDeliveryTime.as_ref(), elapsed)
137                        .with_tag("status", status_tag)
138                        .send();
139                    self.metrics
140                        .incr_with_tags(MetricName::DirectDeliveryStatus)
141                        .with_tag("status", status_tag)
142                        .send();
143                    debug!(
144                        "✉ Error while sending webpush notification to {}: {} ({})",
145                        node_id, error, status_tag
146                    );
147                    self.remove_node_id(&notification.subscription.user, &node_id)
148                        .await?
149                }
150            }
151
152            #[cfg(feature = "reliable_report")]
153            // Couldn't send the message! So revert to the prior state if we have one
154            if let Some(revert_state) = revert_state {
155                trace!(
156                    "🔎⚠️ Revert {:?} from {:?} to {:?}",
157                    &notification.reliability_id, &notification.reliable_state, revert_state
158                );
159                notification
160                    .record_reliability(&self.reliability, revert_state)
161                    .await;
162            }
163        }
164
165        if notification.headers.ttl == 0 {
166            let topic = notification.headers.topic.is_some().to_string();
167            trace!(
168                "✉ Notification has a TTL of zero and was not successfully \
169                 delivered, dropping it"
170            );
171            self.metrics
172                .incr_with_tags(MetricName::NotificationMessageExpired)
173                // TODO: include `internal` if meta is set.
174                .with_tag("topic", &topic)
175                .send();
176            #[cfg(feature = "reliable_report")]
177            notification
178                .record_reliability(
179                    &self.reliability,
180                    autopush_common::reliability::ReliabilityState::Expired,
181                )
182                .await;
183            return Ok(self.make_delivered_response(&notification));
184        }
185
186        // Save notification, node is not present or busy
187        trace!("✉ Node is not present or busy, storing notification");
188        let store_start = Instant::now();
189        self.store_notification(&mut notification).await?;
190        self.metrics
191            .time_with_tags(
192                MetricName::StorageSaveTime.as_ref(),
193                store_start.elapsed().as_millis() as u64,
194            )
195            .send();
196
197        // Retrieve the user data again, they may have reconnected or the node
198        // is no longer busy.
199        let user = match self.db.get_user(&uaid).await {
200            Ok(Some(user)) => user,
201            Ok(None) => {
202                trace!("✉ No user found, must have been deleted");
203                return Err(self.handle_error(
204                    ApiErrorKind::Router(RouterError::UserWasDeleted),
205                    notification.subscription.vapid.clone(),
206                ));
207            }
208            Err(e) => {
209                // Database error, but we already stored the message so it's ok
210                debug!("✉ Database error while re-fetching user: {}", e);
211                return Ok(self.make_stored_response(&notification));
212            }
213        };
214
215        // Try to notify the node the user is currently connected to
216        let node_id = match &user.node_id {
217            Some(id) => id,
218            // The user is not connected to a node, nothing more to do
219            None => {
220                trace!("✉ User is not connected to a node, returning stored response");
221                return Ok(self.make_stored_response(&notification));
222            }
223        };
224
225        // Notify the node to check for messages
226        trace!("✉ Notifying node to check for messages");
227        match self.trigger_notification_check(&user.uaid, node_id).await {
228            Ok(response) => {
229                trace!("Response = {:?}", response);
230                if response.status() == 200 {
231                    trace!("✉ Node has delivered the message");
232                    self.metrics
233                        .time_with_tags(
234                            MetricName::NotificationTotalRequestTime.as_ref(),
235                            (notification.timestamp - autopush_common::util::sec_since_epoch())
236                                * 1000,
237                        )
238                        .with_tag("platform", "websocket")
239                        .with_tag("app_id", "direct")
240                        .send();
241
242                    Ok(self.make_delivered_response(&notification))
243                } else {
244                    trace!("✉ Node has not delivered the message, returning stored response");
245                    Ok(self.make_stored_response(&notification))
246                }
247            }
248            Err(error) => {
249                // Can't communicate with the node, attempt to stop using it
250                debug!("✉ Error while triggering notification check: {}", error);
251                self.remove_node_id(&user, node_id).await?;
252                Ok(self.make_stored_response(&notification))
253            }
254        }
255    }
256
257    /// Use the same sort of error chokepoint that all the mobile clients use.
258    fn handle_error(&self, error: ApiErrorKind, vapid: Option<VapidHeaderWithKey>) -> ApiError {
259        let mut err = ApiError::from(error);
260        if let Some(Ok(claims)) = vapid.map(|v| v.vapid.claims()) {
261            let mut extras = err.extras.unwrap_or_default();
262            if let Some(sub) = claims.sub {
263                extras.extend([("sub".to_owned(), sub)]);
264            }
265            err.extras = Some(extras);
266        };
267        err
268    }
269
270    /// Consume and send the notification to the node
271    async fn send_notification(
272        &self,
273        notification: &Notification,
274        node_id: &str,
275    ) -> ApiResult<Response> {
276        let url = format!("{}/push/{}", node_id, notification.subscription.user.uaid);
277
278        let notification_out = notification.serialize_for_delivery()?;
279
280        trace!(
281            "⏩ out: Notification: {}, channel_id: {} :: {:?}",
282            &notification.subscription.user.uaid,
283            &notification.subscription.channel_id,
284            &notification_out,
285        );
286        self.in_flight_requests.fetch_add(1, Ordering::Relaxed);
287        let result = self.http.put(&url).json(&notification_out).send().await;
288        self.in_flight_requests.fetch_sub(1, Ordering::Relaxed);
289        Ok(result?)
290    }
291
292    /// Notify the node to check for notifications for the user
293    async fn trigger_notification_check(
294        &self,
295        uaid: &Uuid,
296        node_id: &str,
297    ) -> Result<Response, reqwest::Error> {
298        let url = format!("{node_id}/notif/{uaid}");
299
300        self.in_flight_requests.fetch_add(1, Ordering::Relaxed);
301        let result = self.http.put(&url).send().await;
302        self.in_flight_requests.fetch_sub(1, Ordering::Relaxed);
303        result
304    }
305
306    /// Store a notification in the database
307    async fn store_notification(&self, notification: &mut Notification) -> ApiResult<()> {
308        let result = self
309            .db
310            .save_message(
311                &notification.subscription.user.uaid,
312                autopush_common::notification::Notification::from(&*notification),
313            )
314            .await
315            .map_err(|e| {
316                self.handle_error(
317                    ApiErrorKind::Router(RouterError::SaveDb(
318                        e,
319                        // try to extract the `sub` from the VAPID claims.
320                        notification.subscription.vapid.as_ref().map(|vapid| {
321                            vapid
322                                .vapid
323                                .claims()
324                                .ok()
325                                .and_then(|c| c.sub)
326                                .unwrap_or_default()
327                        }),
328                    )),
329                    notification.subscription.vapid.clone(),
330                )
331            });
332        #[cfg(feature = "reliable_report")]
333        notification
334            .record_reliability(
335                &self.reliability,
336                autopush_common::reliability::ReliabilityState::Stored,
337            )
338            .await;
339        result
340    }
341
342    /// Remove the node ID from a user. This is done if the user is no longer
343    /// connected to the node.
344    async fn remove_node_id(&self, user: &User, node_id: &str) -> ApiResult<()> {
345        self.metrics.incr(MetricName::UpdatesClientHostGone).ok();
346        let removed = self
347            .db
348            .remove_node_id(&user.uaid, node_id, user.connected_at, &user.version)
349            .await?;
350        if !removed {
351            // The predicate didn't match (stale version, or the record expired
352            // between our read and this write), so the dead node_id is still on
353            // the record and the next send for this user will pay the connect
354            // timeout all over again.
355            self.metrics.incr(MetricName::ErrorNodeStale).ok();
356            debug!("✉ The node id was not removed");
357        }
358        Ok(())
359    }
360
361    /// Update metrics and create a response for when a notification has been directly forwarded to
362    /// an autopush server.
363    fn make_delivered_response(&self, notification: &Notification) -> RouterResponse {
364        self.make_response(notification, "Direct", StatusCode::CREATED)
365    }
366
367    /// Update metrics and create a response for when a notification has been stored in the database
368    /// for future transmission.
369    fn make_stored_response(&self, notification: &Notification) -> RouterResponse {
370        self.make_response(notification, "Stored", StatusCode::CREATED)
371    }
372
373    /// Update metrics and create a response after routing a notification
374    fn make_response(
375        &self,
376        notification: &Notification,
377        destination_tag: &str,
378        status: StatusCode,
379    ) -> RouterResponse {
380        self.metrics
381            .count_with_tags(
382                MetricName::NotificationMessageData.as_ref(),
383                notification.data.as_ref().map(String::len).unwrap_or(0) as i64,
384            )
385            .with_tag("destination", destination_tag)
386            .send();
387
388        RouterResponse {
389            status: actix_http::StatusCode::from_u16(status.as_u16()).unwrap_or_default(),
390            headers: {
391                let mut map = HashMap::new();
392                map.insert(
393                    "Location",
394                    self.endpoint_url
395                        .join(&format!("/m/{}", notification.message_id))
396                        .expect("Message ID is not URL-safe")
397                        .to_string(),
398                );
399                map.insert("TTL", notification.headers.ttl.to_string());
400                map
401            },
402            body: None,
403        }
404    }
405}
406
407#[cfg(test)]
408mod test {
409    use std::boxed::Box;
410    use std::sync::Arc;
411
412    use reqwest;
413
414    use crate::extractors::subscription::tests::{PUB_KEY, make_vapid};
415    use crate::headers::vapid::VapidClaims;
416    use autopush_common::errors::ReportableError;
417    #[cfg(feature = "reliable_report")]
418    use autopush_common::{redis_util::MAX_TRANSACTION_LOOP, reliability::PushReliability};
419
420    use super::*;
421    use autopush_common::db::mock::MockDbClient;
422
423    fn make_router(db: Box<dyn DbClient>) -> WebPushRouter {
424        let metrics = Arc::new(StatsdClient::builder("", cadence::NopMetricSink).build());
425        WebPushRouter {
426            db: db.clone(),
427            metrics: metrics.clone(),
428            http: reqwest::Client::new(),
429            endpoint_url: Url::parse("http://localhost:8080/").unwrap(),
430            in_flight_requests: Arc::new(AtomicUsize::new(0)),
431            #[cfg(feature = "reliable_report")]
432            reliability: Arc::new(
433                PushReliability::new(&None, db, &metrics, MAX_TRANSACTION_LOOP).unwrap(),
434            ),
435        }
436    }
437
438    #[tokio::test]
439    async fn pass_extras() {
440        let db = MockDbClient::new().into_boxed_arc();
441        let router = make_router(db);
442        let sub = "foo@example.com";
443        let vapid = make_vapid(
444            sub,
445            "https://push.services.mozilla.org",
446            VapidClaims::default_exp(),
447            PUB_KEY.to_owned(),
448        );
449
450        let err = router.handle_error(ApiErrorKind::LogCheck, Some(vapid));
451        assert!(err.extras().contains(&("sub", sub.to_owned())));
452    }
453}