steid

@jamesgill /

steid/src/domain/webhook_delivery.rs
Rust5.3 KBCode·Blame·History·Raw
1//! One attempt at delivering one event to one hook.
2//!
3//! A row per *attempt*, not per delivery: the useful question when a hook is broken is
4//! "what did it say, and how many times did we try", and collapsing the retries into a
5//! final state throws away the only evidence of what went wrong.
6
7use std::time::SystemTime;
8
9use super::{PushEventId, WebhookDeliveryId, WebhookId};
10
11/// What one attempt did.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct WebhookDelivery {
14 pub id: WebhookDeliveryId,
15 pub webhook_id: WebhookId,
16 pub event_id: PushEventId,
17 /// 1 for the first send, incrementing per retry.
18 pub attempt: u32,
19 /// The HTTP status, or `None` when the request never got one — DNS, connection
20 /// refused, TLS, or the timeout.
21 pub status: Option<u16>,
22 /// The start of the receiver's response body, capped. Enough to see an error
23 /// message, far short of storing whatever a hostile endpoint returns.
24 pub response: String,
25 pub duration_ms: u64,
26 pub delivered_at: SystemTime,
27 /// Whether the receiver accepted it: a 2xx and nothing else.
28 pub ok: bool,
29}
30
31impl WebhookDelivery {
32 /// How much of a response body is kept.
33 ///
34 /// A row, not a log: the page shows this inline, and an endpoint that answers a
35 /// megabyte of HTML must not put a megabyte in the database per attempt.
36 pub const MAX_RESPONSE: usize = 500;
37
38 /// Records an attempt.
39 ///
40 /// `ok` is derived from the status rather than passed in, so a caller cannot record
41 /// a 500 as a success. There is no `new`/`from_trusted` split for the *rest*: every
42 /// field here is measured rather than typed by anyone, so there is nothing a
43 /// validating constructor would be validating.
44 #[allow(clippy::too_many_arguments)]
45 pub fn record(
46 id: WebhookDeliveryId,
47 webhook_id: WebhookId,
48 event_id: PushEventId,
49 attempt: u32,
50 status: Option<u16>,
51 response: impl AsRef<str>,
52 duration_ms: u64,
53 delivered_at: SystemTime,
54 ) -> Self {
55 Self {
56 id,
57 webhook_id,
58 event_id,
59 attempt,
60 status,
61 response: truncate(response.as_ref(), Self::MAX_RESPONSE),
62 duration_ms,
63 delivered_at,
64 ok: status.is_some_and(|status| (200..300).contains(&status)),
65 }
66 }
67
68 /// Rebuilds a row from storage without recomputing `ok`.
69 #[allow(clippy::too_many_arguments)]
70 pub fn from_trusted(
71 id: WebhookDeliveryId,
72 webhook_id: WebhookId,
73 event_id: PushEventId,
74 attempt: u32,
75 status: Option<u16>,
76 response: impl Into<String>,
77 duration_ms: u64,
78 delivered_at: SystemTime,
79 ok: bool,
80 ) -> Self {
81 Self {
82 id,
83 webhook_id,
84 event_id,
85 attempt,
86 status,
87 response: response.into(),
88 duration_ms,
89 delivered_at,
90 ok,
91 }
92 }
93
94 /// What to show in a status column when there is no status code.
95 pub fn status_label(&self) -> String {
96 match self.status {
97 Some(status) => status.to_string(),
98 None => "no reply".to_owned(),
99 }
100 }
101}
102
103/// Cuts a string to at most `max` bytes, on a character boundary.
104///
105/// Byte-bounded rather than character-bounded because the cap exists to bound a column,
106/// and a response body is arbitrary bytes lossily decoded — a character limit would let
107/// one full of astral-plane text be four times the size.
108fn truncate(value: &str, max: usize) -> String {
109 if value.len() <= max {
110 return value.to_owned();
111 }
112
113 let mut end = max;
114 while end > 0 && !value.is_char_boundary(end) {
115 end -= 1;
116 }
117
118 value[..end].to_owned()
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn delivery(status: Option<u16>, response: &str) -> WebhookDelivery {
126 WebhookDelivery::record(
127 WebhookDeliveryId::generate(),
128 WebhookId::generate(),
129 PushEventId::generate(),
130 1,
131 status,
132 response,
133 12,
134 SystemTime::UNIX_EPOCH,
135 )
136 }
137
138 #[test]
139 fn only_a_2xx_is_a_success() {
140 assert!(delivery(Some(200), "").ok);
141 assert!(delivery(Some(204), "").ok);
142 assert!(delivery(Some(299), "").ok);
143 assert!(!delivery(Some(301), "").ok);
144 assert!(!delivery(Some(404), "").ok);
145 assert!(!delivery(Some(500), "").ok);
146 assert!(!delivery(None, "connection refused").ok);
147 }
148
149 #[test]
150 fn a_response_is_capped() {
151 let long = "x".repeat(WebhookDelivery::MAX_RESPONSE * 3);
152
153 assert_eq!(
154 delivery(Some(500), &long).response.len(),
155 WebhookDelivery::MAX_RESPONSE
156 );
157 }
158
159 #[test]
160 fn capping_never_splits_a_character() {
161 // Three bytes each, so the cap lands mid-character and has to walk back.
162 let long = "😀".repeat(WebhookDelivery::MAX_RESPONSE);
163 let capped = delivery(Some(500), &long).response;
164
165 assert!(capped.len() <= WebhookDelivery::MAX_RESPONSE);
166 assert!(capped.chars().all(|c| c == '😀'));
167 }
168
169 #[test]
170 fn a_failure_with_no_status_says_so() {
171 assert_eq!(delivery(None, "").status_label(), "no reply");
172 assert_eq!(delivery(Some(502), "").status_label(), "502");
173 }
174}