1use std::{sync::Arc, time::Duration};
12
13use chrono::{DateTime, TimeDelta, Utc};
14use reqwest::{StatusCode, header};
15use serde::Serialize;
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use super::{
20 Destination, Event, Kind, Webhooks,
21 destination::Target,
22 render::{self, Markup},
23 sign,
24};
25use crate::error::ApiError;
26
27const TICK: Duration = Duration::from_secs(5);
33
34const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
36
37const BURST: usize = 50;
40
41const RETRY_AFTER: [TimeDelta; 8] = [
45 TimeDelta::seconds(30),
46 TimeDelta::minutes(2),
47 TimeDelta::minutes(10),
48 TimeDelta::minutes(30),
49 TimeDelta::hours(1),
50 TimeDelta::hours(3),
51 TimeDelta::hours(6),
52 TimeDelta::hours(12),
53];
54
55const LONGEST_REQUESTED_WAIT: TimeDelta = TimeDelta::hours(1);
57
58const ERROR_BODY: usize = 300;
61
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
64pub struct Dispatched {
65 pub delivered: u64,
66 pub retrying: u64,
68 pub abandoned: u64,
71}
72
73pub fn client() -> reqwest::Client {
79 let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
80 let tls = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
81 .with_safe_default_protocol_versions()
82 .expect("ring supports the default protocol versions")
83 .with_root_certificates(roots)
84 .with_no_client_auth();
85
86 reqwest::Client::builder()
87 .use_preconfigured_tls(tls)
88 .timeout(REQUEST_TIMEOUT)
89 .user_agent(concat!("kasl-server/", env!("CARGO_PKG_VERSION")))
90 .redirect(reqwest::redirect::Policy::none())
93 .build()
94 .expect("the HTTP client builds from a fixed configuration")
95}
96
97#[derive(Debug, sqlx::FromRow)]
99struct Pending {
100 id: Uuid,
101 attempts: i32,
102 next_attempt_at: DateTime<Utc>,
103 payload: sqlx::types::Json<Event>,
104}
105
106#[derive(Debug, PartialEq, Eq)]
108enum Outcome {
109 Delivered,
110 Retry {
112 status: Option<u16>,
113 error: String,
114 wait: Option<TimeDelta>,
115 },
116 Refused {
119 status: u16,
120 error: String,
121 },
122}
123
124pub async fn dispatch_due(pool: &PgPool, webhooks: &Webhooks, client: &reqwest::Client, now: DateTime<Utc>) -> Result<Dispatched, ApiError> {
126 let mut dispatched = Dispatched::default();
127
128 let waiting: Vec<String> = sqlx::query_scalar("SELECT DISTINCT destination FROM webhook_deliveries WHERE delivered_at IS NULL AND abandoned_at IS NULL")
129 .fetch_all(pool)
130 .await?;
131
132 for name in waiting {
133 let Some(destination) = webhooks.get(&name) else {
134 let dropped = sqlx::query(
138 "UPDATE webhook_deliveries SET abandoned_at = $2, last_error = $3
139 WHERE destination = $1 AND delivered_at IS NULL AND abandoned_at IS NULL",
140 )
141 .bind(&name)
142 .bind(now)
143 .bind(format!("no destination named `{name}` is configured any more"))
144 .execute(pool)
145 .await?;
146 dispatched.abandoned += dropped.rows_affected();
147 continue;
148 };
149
150 for _ in 0..BURST {
151 let head: Option<Pending> = sqlx::query_as(
152 "SELECT id, attempts, next_attempt_at, payload FROM webhook_deliveries
153 WHERE destination = $1 AND delivered_at IS NULL AND abandoned_at IS NULL
154 ORDER BY created_at, id
155 LIMIT 1",
156 )
157 .bind(&name)
158 .fetch_optional(pool)
159 .await?;
160
161 let Some(head) = head.filter(|head| head.next_attempt_at <= now) else { break };
164
165 let outcome = send(client, destination, &head.payload, now).await;
166 let attempts = head.attempts + 1;
167 match outcome {
168 Outcome::Delivered => {
169 sqlx::query("UPDATE webhook_deliveries SET delivered_at = $2, attempts = $3, last_status = NULL, last_error = NULL WHERE id = $1")
170 .bind(head.id)
171 .bind(now)
172 .bind(attempts)
173 .execute(pool)
174 .await?;
175 dispatched.delivered += 1;
176 continue;
177 }
178 Outcome::Retry { status, error, wait } => {
179 let error = destination.redact(&error);
180 match RETRY_AFTER.get(head.attempts as usize) {
181 Some(scheduled) => {
182 let wait = wait.map_or(*scheduled, |asked| asked.clamp(*scheduled, LONGEST_REQUESTED_WAIT.max(*scheduled)));
183 sqlx::query("UPDATE webhook_deliveries SET attempts = $2, next_attempt_at = $3, last_status = $4, last_error = $5 WHERE id = $1")
184 .bind(head.id)
185 .bind(attempts)
186 .bind(now + wait)
187 .bind(status.map(i32::from))
188 .bind(&error)
189 .execute(pool)
190 .await?;
191 tracing::warn!(destination = %name, attempts, %error, "a webhook delivery failed; it will be tried again");
192 dispatched.retrying += 1;
193 }
194 None => {
195 abandon(pool, head.id, attempts, now, status, &format!("gave up after {attempts} attempts: {error}")).await?;
196 tracing::warn!(destination = %name, attempts, %error, "gave up on a webhook delivery");
197 dispatched.abandoned += 1;
198 }
199 }
200 }
201 Outcome::Refused { status, error } => {
202 let error = destination.redact(&error);
203 abandon(pool, head.id, attempts, now, Some(status), &error).await?;
204 tracing::warn!(destination = %name, status, %error, "a webhook receiver refused a delivery; not trying again");
205 dispatched.abandoned += 1;
206 }
207 }
208 break;
209 }
210 }
211
212 Ok(dispatched)
213}
214
215async fn abandon(pool: &PgPool, id: Uuid, attempts: i32, now: DateTime<Utc>, status: Option<u16>, error: &str) -> Result<(), ApiError> {
216 sqlx::query("UPDATE webhook_deliveries SET abandoned_at = $2, attempts = $3, last_status = $4, last_error = $5 WHERE id = $1")
217 .bind(id)
218 .bind(now)
219 .bind(attempts)
220 .bind(status.map(i32::from))
221 .bind(error)
222 .execute(pool)
223 .await?;
224 Ok(())
225}
226
227async fn send(client: &reqwest::Client, destination: &Destination, event: &Event, now: DateTime<Utc>) -> Outcome {
229 let request = match (&destination.target, destination.kind) {
230 (Target::Hook { url }, Kind::Slack) => client.post(url).json(&render::hook_body(render::text(event, destination, Markup::Slack))),
231 (Target::Hook { url }, _) => client.post(url).json(&render::hook_body(render::text(event, destination, Markup::Markdown))),
232 (Target::Telegram { token, chat, api }, _) => client
233 .post(format!("{api}/bot{token}/sendMessage"))
234 .json(&render::telegram_body(chat, render::text(event, destination, Markup::TelegramHtml))),
235 (Target::Json { url, secret }, _) => {
236 let body = match serde_json::to_vec(event) {
237 Ok(body) => body,
238 Err(error) => {
239 return Outcome::Refused {
240 status: 0,
241 error: format!("the event could not be written as JSON: {error}"),
242 };
243 }
244 };
245 client
246 .post(url)
247 .header(header::CONTENT_TYPE, "application/json")
248 .header("X-Kasl-Event", event.event.name())
249 .header("X-Kasl-Event-Id", event.id.to_string())
250 .header("X-Kasl-Signature", sign::signature(secret, now.timestamp(), &body))
251 .body(body)
252 }
253 };
254
255 let response = match request.send().await {
256 Ok(response) => response,
257 Err(error) => {
260 return Outcome::Retry {
261 status: None,
262 error: describe(&error.without_url()),
263 wait: None,
264 };
265 }
266 };
267
268 let status = response.status();
269 if status.is_success() {
270 return Outcome::Delivered;
271 }
272
273 let wait = retry_after(&response);
274 let body: String = response.text().await.unwrap_or_default().chars().take(ERROR_BODY).collect();
275 let error = if body.trim().is_empty() {
276 format!("the receiver answered {status}")
277 } else {
278 format!("the receiver answered {status}: {}", body.trim())
279 };
280
281 classify(status, error, wait)
282}
283
284fn classify(status: StatusCode, error: String, wait: Option<TimeDelta>) -> Outcome {
292 let code = status.as_u16();
293 if status.is_client_error() && code != 408 && code != 429 {
294 Outcome::Refused { status: code, error }
295 } else {
296 Outcome::Retry {
297 status: Some(code),
298 error,
299 wait,
300 }
301 }
302}
303
304fn retry_after(response: &reqwest::Response) -> Option<TimeDelta> {
308 let seconds: i64 = response.headers().get(header::RETRY_AFTER)?.to_str().ok()?.trim().parse().ok()?;
309 (seconds >= 0).then(|| TimeDelta::seconds(seconds))
310}
311
312fn describe(error: &reqwest::Error) -> String {
315 let mut text = error.to_string();
316 let mut source = std::error::Error::source(error);
317 while let Some(cause) = source {
318 let cause_text = cause.to_string();
319 if !text.contains(&cause_text) {
320 text.push_str(": ");
321 text.push_str(&cause_text);
322 }
323 source = cause.source();
324 }
325 text
326}
327
328pub fn run_dispatcher(pool: PgPool, webhooks: Arc<Webhooks>) {
334 tokio::spawn(async move {
335 let client = client();
336 let mut ticker = tokio::time::interval(TICK);
337 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
338 loop {
339 ticker.tick().await;
340 match dispatch_due(&pool, &webhooks, &client, Utc::now()).await {
341 Ok(done) if done != Dispatched::default() => {
342 tracing::info!(
343 delivered = done.delivered,
344 retrying = done.retrying,
345 abandoned = done.abandoned,
346 "dispatched webhooks"
347 );
348 }
349 Ok(_) => {}
350 Err(error) => tracing::warn!(%error, "a webhook dispatch failed; the next one picks up where it left off"),
351 }
352 }
353 });
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn a_refusal_is_final_and_a_bad_moment_is_not() {
362 for code in [400, 401, 403, 404, 410] {
363 assert!(
364 matches!(classify(StatusCode::from_u16(code).unwrap(), String::new(), None), Outcome::Refused { .. }),
365 "{code} will not change by being asked again"
366 );
367 }
368 for code in [408, 429, 500, 502, 503, 504] {
369 assert!(
370 matches!(classify(StatusCode::from_u16(code).unwrap(), String::new(), None), Outcome::Retry { .. }),
371 "{code} is worth another try"
372 );
373 }
374 }
375
376 #[test]
377 fn the_retries_span_about_a_day() {
378 let total: TimeDelta = RETRY_AFTER.iter().copied().sum();
379 assert!(total > TimeDelta::hours(20) && total < TimeDelta::hours(26), "{total}");
380 assert!(RETRY_AFTER.windows(2).all(|pair| pair[0] < pair[1]), "each wait longer than the last");
381 }
382}