LCOV - code coverage report
Current view: top level - antares-api/src/notify - retry.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 95.4 % 131 125
Test Date: 2026-11-02 13:21:27 Functions: 30.8 % 52 16

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Retry policy and dead letters: settle a failed delivery, park it, replay it.
       3              : 
       4              : use super::*;
       5              : 
       6              : pub(super) static DEAD_LETTERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
       7              : 
       8              : /// Dead letters written by this process since start (`/q/health`
       9              : /// deadLetters); the letters themselves live in the store.
      10           94 : pub(crate) fn dead_letters_written() -> u64 {
      11           94 :     DEAD_LETTERS.load(std::sync::atomic::Ordering::Relaxed)
      12           94 : }
      13              : 
      14              : /// The retries the delivery policy grants after a failed first attempt,
      15              : /// then the settlement: lastSuccess/status ok on success, a dead letter
      16              : /// when the policy is exhausted.
      17              : #[cfg(not(target_arch = "wasm32"))]
      18              : #[allow(clippy::too_many_arguments)] // one param per piece of the attempt's state
      19           16 : pub(super) async fn retry_and_settle(
      20           16 :     st: &AppState,
      21           16 :     tenant: &TenantId,
      22           16 :     kind: Kind,
      23           16 :     sub_id: &str,
      24           16 :     uri: &str,
      25           16 :     timeout_ms: u32,
      26           16 :     outbound: Outbound,
      27           16 :     first_err: String,
      28           16 : ) {
      29           16 :     let policy = st.delivery;
      30           16 :     let started = std::time::Instant::now();
      31           16 :     let first_at = now_iso();
      32           16 :     let mut made = 1u32;
      33           16 :     let mut last_err = first_err.clone();
      34           24 :     while let Some(delay) = policy.next_delay(made, started.elapsed()) {
      35           20 :         tokio::time::sleep(delay).await;
      36              :         // the subscription may have gone, or its endpoint may have tripped
      37              :         // the breaker meanwhile — a retry is still one more attempt
      38           16 :         if st
      39           16 :             .store
      40           16 :             .get(tenant, kind, sub_id)
      41           16 :             .await
      42           16 :             .ok()
      43           16 :             .flatten()
      44           16 :             .is_none()
      45              :         {
      46            4 :             return;
      47           12 :         }
      48           12 :         made = made.saturating_add(1);
      49           12 :         match send_outbound(st, tenant, uri, timeout_ms, &outbound).await {
      50              :             Ok(()) => {
      51            4 :                 st.egress.record_success(tenant.as_str(), uri);
      52            4 :                 metrics::counter!("antares_notifications_retried_total", "outcome" => "ok")
      53            4 :                     .increment(1);
      54            4 :                 let ts = now_iso();
      55            4 :                 let mut retried_doc: Option<Value> = None;
      56            4 :                 st.store
      57            4 :                     .mutate(tenant, kind, sub_id, |doc| {
      58            4 :                         if let Some(o) = doc.as_object_mut() {
      59            4 :                             o.remove("status");
      60            4 :                         }
      61            4 :                         if let Some(n) = doc
      62            4 :                             .as_object_mut()
      63            4 :                             .and_then(|o| o.get_mut("notification"))
      64            4 :                             .and_then(Value::as_object_mut)
      65            4 :                         {
      66            4 :                             n.insert("lastSuccess".into(), Value::String(ts.clone()));
      67            4 :                             n.insert("status".into(), Value::String("ok".into()));
      68            4 :                         }
      69            4 :                         retried_doc = Some(doc.clone());
      70            4 :                         Ok::<(), antares_model::NgsiError>(())
      71            4 :                     })
      72            4 :                     .await
      73            4 :                     .unwrap_or_else(|e| {
      74            0 :                         tracing::warn!("retry bookkeeping writeback failed: {e}");
      75            0 :                         None
      76            0 :                     });
      77            4 :                 mirror_bookkeeping(st, tenant, kind, sub_id, retried_doc);
      78            4 :                 return;
      79              :             }
      80            8 :             Err((timed_out, e)) => {
      81            8 :                 if timed_out {
      82            0 :                     st.egress.record_failure(tenant.as_str(), uri);
      83            8 :                 } else {
      84            8 :                     st.egress.record_success(tenant.as_str(), uri);
      85            8 :                 }
      86            8 :                 last_err = e;
      87              :             }
      88              :         }
      89              :     }
      90            4 :     metrics::counter!("antares_notifications_retried_total", "outcome" => "dead").increment(1);
      91            4 :     let letter = dead_letter(
      92            4 :         sub_id, uri, timeout_ms, &outbound, made, &first_err, &last_err, &first_at,
      93              :     );
      94            4 :     let id = letter
      95            4 :         .get("id")
      96            4 :         .and_then(Value::as_str)
      97            4 :         .unwrap_or_default()
      98            4 :         .to_owned();
      99            4 :     match st.store.create(tenant, Kind::DeadLetter, &id, letter).await {
     100              :         Ok(_) => {
     101            4 :             DEAD_LETTERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
     102            4 :             tracing::warn!(
     103              :                 "notification for {sub_id} to {} dead-lettered after {made} attempts: {last_err}",
     104            0 :                 redact_userinfo(uri)
     105              :             );
     106              :         }
     107            0 :         Err(e) => tracing::error!("dead letter for {sub_id} could not be stored: {e}"),
     108              :     }
     109           12 : }
     110              : 
     111              : /// The dead-letter document: everything a replay needs to send the very
     112              : /// same request again, plus the attempt history. Written by the native
     113              : /// retry loop (`retry_and_settle`) alone.
     114              : #[cfg(not(target_arch = "wasm32"))]
     115              : #[allow(clippy::too_many_arguments)] // one param per stored letter member
     116            4 : pub(super) fn dead_letter(
     117            4 :     sub_id: &str,
     118            4 :     uri: &str,
     119            4 :     timeout_ms: u32,
     120            4 :     outbound: &Outbound,
     121            4 :     attempts: u32,
     122            4 :     first_err: &str,
     123            4 :     last_err: &str,
     124            4 :     first_at: &str,
     125            4 : ) -> Value {
     126            4 :     json!({
     127            4 :         "id": format!("urn:ngsi-ld:DeadLetter:{}", uuid::Uuid::new_v4()),
     128            4 :         "type": "DeadLetter",
     129            4 :         "subscriptionId": sub_id,
     130            4 :         "uri": uri,
     131            4 :         "timeoutMs": timeout_ms,
     132            4 :         "attempts": attempts,
     133            4 :         "firstError": first_err,
     134            4 :         "lastError": last_err,
     135            4 :         "firstAt": first_at,
     136            4 :         "lastAt": now_iso(),
     137            4 :         "binding": antares_notifier::SinkRegistry::scheme_of(uri),
     138            4 :         "payload": outbound.body.clone(),
     139            4 :         "accept": outbound.accept,
     140            4 :         "link": outbound.link,
     141            4 :         "receiverInfo": outbound.receiver_info,
     142            4 :         "notifierInfo": outbound.notifier_info,
     143              :     })
     144            4 : }
     145              : 
     146              : /// The deadline a dead letter was written with. It is read back from
     147              : /// storage, so a value past u32 caps at the 30 s ceiling of
     148              : /// [`endpoint_timeout_ms`] instead of wrapping to a short deadline.
     149           26 : pub(super) fn letter_timeout_ms(letter: &Value) -> u32 {
     150           26 :     letter["timeoutMs"]
     151           26 :         .as_u64()
     152           26 :         .map_or(5_000, |t| u32::try_from(t.min(30_000)).unwrap_or(30_000))
     153           26 : }
     154              : 
     155              : /// Replay one dead letter through the same binding, once. `Ok` = delivered
     156              : /// (the caller deletes the letter); `Err` carries the failure text.
     157           16 : pub(crate) async fn replay_dead_letter(
     158           16 :     st: &AppState,
     159           16 :     tenant: &TenantId,
     160           16 :     letter: &Value,
     161           16 : ) -> Result<(), String> {
     162           16 :     let uri = letter["uri"].as_str().ok_or("dead letter without uri")?;
     163           14 :     let timeout_ms = letter_timeout_ms(letter);
     164           14 :     let outbound = Outbound::from_dead_letter(letter)?;
     165              :     // the egress policy of the moment applies, exactly as for a fresh send
     166           12 :     if in_process(st, uri).is_none() && st.sinks.sink_for_uri(uri).is_some_and(|s| s.network()) {
     167            6 :         st.egress
     168            6 :             .check_destination(uri)
     169            6 :             .await
     170            6 :             .map_err(|e| e.to_string())?;
     171            6 :     }
     172           10 :     send_outbound(st, tenant, uri, timeout_ms, &outbound)
     173           10 :         .await
     174           10 :         .map_err(|(_, e)| e)
     175           16 : }
        

Generated by: LCOV version 2.0-1