Skip to main content

hook0_client/
lib.rs

1// Force exposed items to be documented
2#![deny(missing_docs)]
3
4//! This is the Rust client for Hook0.
5//! It makes it easier to send events from a Rust application to a Hook0 instance.
6//!
7//! # Sending an event is idempotent, and retried
8//!
9//! [`Hook0Client::send_event`] sends every event under an ID it knows: the one set on the
10//! [`Event`], or a UUIDv7 it generates when the event carries none. Passing no ID no longer means
11//! the ID comes from Hook0 — the interface is unchanged, but the value now comes from the client,
12//! is sent with the request and is what `send_event` returns.
13//!
14//! That is what makes retrying safe. Hook0 keys events on their ID, so a request that is repeated
15//! after a network failure or a server error ingests the event once rather than twice; without a
16//! client-chosen ID, a repeated request would create a second event and deliver it to every
17//! subscriber.
18//!
19//! Every send is bounded, and every bound is configurable:
20//! [`Hook0Client::with_max_payload_bytes`] rules an oversized payload out before anything is sent,
21//! [`Hook0Client::with_request_timeout`] bounds one attempt,
22//! [`Hook0Client::with_max_response_bytes`] bounds what an answer may cost to read, and
23//! [`RetryPolicy`] bounds how many attempts are made and how long they may spend waiting between
24//! them. Pass [`RetryPolicy::disabled`] to send each event exactly once.
25
26#[cfg(all(not(feature = "producer"), not(feature = "consumer")))]
27compile_error!("at least one of feature \"producer\" and feature \"consumer\" must be enabled");
28
29use chrono::{DateTime, Utc};
30
31#[cfg(feature = "producer")]
32use lazy_regex::regex_captures;
33#[cfg(feature = "producer")]
34use reqwest::StatusCode;
35#[cfg(feature = "producer")]
36use reqwest::header::{
37    ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, InvalidHeaderValue, RETRY_AFTER,
38};
39#[cfg(feature = "producer")]
40use reqwest::{Client, ResponseBuilderExt, Url};
41#[cfg(feature = "producer")]
42use serde::ser::Error as SerializationError;
43#[cfg(feature = "producer")]
44use serde::{Deserialize, Serialize, Serializer};
45#[cfg(feature = "producer")]
46use std::borrow::Cow;
47#[cfg(feature = "producer")]
48use std::collections::hash_map::RandomState;
49#[cfg(feature = "producer")]
50use std::collections::{HashMap, HashSet};
51#[cfg(feature = "producer")]
52use std::fmt::Display;
53#[cfg(feature = "producer")]
54use std::hash::{BuildHasher, Hasher};
55#[cfg(feature = "producer")]
56use std::str::FromStr;
57#[cfg(feature = "producer")]
58use tracing::{debug, error, trace};
59#[cfg(feature = "producer")]
60use url::ParseError;
61#[cfg(feature = "producer")]
62use uuid::Uuid;
63
64#[cfg(feature = "consumer")]
65use chrono::{Duration, OutOfRangeError};
66#[cfg(any(feature = "consumer", feature = "producer"))]
67use std::time::Duration as StdDuration;
68#[cfg(feature = "consumer")]
69mod signature;
70
71/// Everything the API document describes, written by the SDK generator and never by hand.
72///
73/// It is reached as a module rather than flattened into this one on purpose. The document declares
74/// schemas called `Event` and `EventType`, which are the API's own resources and not the [`Event`]
75/// an emitter fills in here; re-exporting them side by side would either fail to compile or, worse,
76/// let a glob quietly drop whichever one lost. Under a module of its own, every name the document
77/// declares is reachable, unambiguous, and safe for the API to add to.
78///
79/// It follows the `producer` feature because everything it declares is the control plane of the
80/// API: a consumer that only verifies webhook signatures pulls in none of it.
81#[cfg(feature = "producer")]
82pub mod generated;
83
84#[cfg(feature = "producer")]
85/// Longest one attempt at reaching Hook0 is given before it is abandoned, unless the client is
86/// told otherwise with [`Hook0Client::with_request_timeout`].
87///
88/// Ten seconds is far above what ingesting an event takes when the API is healthy, and short
89/// enough that a stuck connection does not hold an emitter's task for a noticeable time.
90pub const DEFAULT_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10);
91
92#[cfg(feature = "producer")]
93/// Largest event payload the client agrees to send, unless it is told otherwise with
94/// [`Hook0Client::with_max_payload_bytes`].
95///
96/// The API holds a payload to 699,050 characters, and its body to 2 MiB. Neither is this number,
97/// and the difference is worth knowing before changing it. That character limit counts characters
98/// rather than bytes, so text that is one byte per character is refused by the API well before
99/// this cap is reached, and the cap costs nothing. Text that is not, such as a payload written in
100/// a script outside ASCII, reaches a megabyte while still being far short of 699,050 characters,
101/// and there this cap is what refuses it. The client rules the event out rather than spending a
102/// round trip, and every retry after it, on a request it expects the body limit to reject.
103pub const DEFAULT_MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
104
105#[cfg(feature = "producer")]
106/// Largest answer the client reads off the socket, unless it is told otherwise with
107/// [`Hook0Client::with_max_response_bytes`].
108///
109/// The body of an answer is written by the other end: a server that is broken or hostile can
110/// otherwise stream into an emitter's memory for as long as the connection lasts, and a client with
111/// no ceiling has no answer to that. Eight mebibytes is far above anything Hook0's API replies with,
112/// and the read stops there rather than growing with whatever arrives.
113pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
114
115#[cfg(feature = "producer")]
116/// Header lines an answer may carry before this client refuses to read it.
117///
118/// The head is written by the other end just like the body, so it is bounded like the body: a
119/// client that holds a head of any length has only moved where a broken or hostile server spends
120/// its caller's memory. This one and [`MAX_HEADER_BYTES`] refuse early, on the line that crosses
121/// them, rather than at the end of the head.
122pub const MAX_RESPONSE_HEADERS: usize = 64;
123
124#[cfg(feature = "producer")]
125/// Longest one header line may be, its name and its value together, in bytes.
126pub const MAX_HEADER_BYTES: usize = 64 * 1024;
127
128#[cfg(feature = "producer")]
129/// Largest whole head an answer may carry, every line counted together, in bytes.
130///
131/// This is the one that bounds what a head costs: a line count and a size per line multiply, and
132/// [`MAX_RESPONSE_HEADERS`] lines of [`MAX_HEADER_BYTES`] each is four mebibytes of head that both
133/// of them admit. Sixteen kibibytes is what the strictest runtime any Hook0 SDK runs on enforces by
134/// default, and matching it is the point: a lower ceiling would refuse heads another SDK accepts,
135/// and a higher one would not bind there at all.
136pub const MAX_HEAD_BYTES: usize = 16 * 1024;
137
138#[cfg(feature = "producer")]
139/// Most attempts a [`RetryPolicy`] can ever make, whatever `max_attempts` says.
140///
141/// A policy is configuration, and configuration can be wrong; this cap keeps a mistyped
142/// `max_attempts` from turning one send into an unbounded series of requests.
143pub const MAX_ATTEMPTS_CAP: u32 = 16;
144
145#[cfg(feature = "producer")]
146/// What every request says it carries, and what every answer is asked for in.
147const JSON_MEDIA_TYPE: &str = "application/json";
148
149#[cfg(feature = "producer")]
150/// Longest each part this client composes its `User-Agent` out of may be, in characters.
151///
152/// The runtime and the operating system are described by the platform rather than by this crate,
153/// so their length is not this crate's to guarantee: they are cut here so that the header cannot
154/// grow with whatever the platform feels like saying. Every part is also stripped of anything the
155/// grammar of the header uses as punctuation, so a platform cannot forge a shape it does not have.
156const MAX_USER_AGENT_PART_CHARS: usize = 64;
157
158#[cfg(feature = "producer")]
159/// Which SDK, at which version, on which runtime and operating system, is talking to the API.
160///
161/// The version is read from the manifest of this crate rather than written down again here: one
162/// remembered in two places is one that will disagree with itself the first time it is bumped.
163fn user_agent() -> String {
164    let version = clipped(env!("CARGO_PKG_VERSION"));
165    // Nothing in the standard library answers which compiler built this, so the runtime is named
166    // and not versioned; the operating system and the architecture are what it runs on.
167    let os = clipped(&format!(
168        "{} {}",
169        std::env::consts::OS,
170        std::env::consts::ARCH
171    ));
172    format!("hook0-client-rust/{version} (rust; {os})")
173}
174
175#[cfg(feature = "producer")]
176/// One part of the `User-Agent`, with everything the header's own grammar uses taken out of it and
177/// cut to [`MAX_USER_AGENT_PART_CHARS`].
178fn clipped(part: &str) -> String {
179    part.chars()
180        .filter(|c| c.is_ascii_graphic() || *c == ' ')
181        .filter(|c| !matches!(c, '(' | ')' | ';'))
182        .take(MAX_USER_AGENT_PART_CHARS)
183        .collect()
184}
185
186#[cfg(feature = "producer")]
187/// Name of the header every request states the retry policy behind it under.
188const CLIENT_OPTIONS: &str = "Hook0-Client-Options";
189
190#[cfg(feature = "producer")]
191/// Longest delay the header every request carries can state, in milliseconds.
192///
193/// A [`StdDuration`] reaches far past the delays the SDKs in other languages can hold, let alone
194/// state: [`StdDuration::MAX`] is twenty-three digits of milliseconds, which a reader holding it as
195/// a double reads back as a different number. This is the largest whole number every runtime
196/// reading the header holds exactly, and it is the same ceiling in every SDK so that two of them
197/// cannot describe one unbounded policy differently.
198const MAX_STATED_DELAY_MS: u128 = (1 << 53) - 1;
199
200#[cfg(feature = "producer")]
201/// The retry policy behind a request, as the header every request carries states it.
202///
203/// The four parts are the policy in force, in the order the shared contract fixes, joined the way
204/// `X-Hook0-Signature` joins its own: every duration is a count of milliseconds and every part an
205/// integer, so an instance reads the value back by cutting each part at its first `=` and nothing
206/// here needs a parser of its own. Integers are also what bounds the value without cutting it down
207/// to a length: four of them are as long as their widths allow and no longer, whatever a caller
208/// configures.
209///
210/// In force means past this client's own bounds rather than as asked for: a policy that asked for a
211/// thousand attempts states the [`MAX_ATTEMPTS_CAP`] it will make, since a thousand would have a
212/// reader watching for a burst that cannot arrive, and a delay longer than any SDK can state states
213/// [`MAX_STATED_DELAY_MS`].
214fn client_options(policy: &RetryPolicy) -> String {
215    format!(
216        "attempts={},backoff={},ceiling={},budget={}",
217        policy.attempts(),
218        stated_delay(policy.initial_backoff),
219        stated_delay(policy.max_backoff),
220        stated_delay(policy.max_total_delay),
221    )
222}
223
224#[cfg(feature = "producer")]
225/// One delay of a policy, as the whole milliseconds the header states it in, capped at
226/// [`MAX_STATED_DELAY_MS`].
227fn stated_delay(delay: StdDuration) -> u128 {
228    delay.as_millis().min(MAX_STATED_DELAY_MS)
229}
230
231#[cfg(feature = "producer")]
232/// Public identifier Hook0 gives the problem it answers when an event ID is already taken.
233const ALREADY_INGESTED: &str = "EventAlreadyIngested";
234
235#[cfg(feature = "producer")]
236/// Public identifier Hook0 gives the problem it answers when requests are reaching the instance
237/// faster than it accepts them.
238///
239/// It shares its status with the quota problems and is the only one of them worth repeating: a
240/// quota clears when a plan changes or a day turns, neither of which happens inside the seconds a
241/// send is given, while pacing clears on its own and the answer says when.
242const RATE_LIMITED: &str = "RateLimited";
243
244#[cfg(feature = "producer")]
245/// How a client spaces out the attempts of a single send.
246///
247/// The delay before a retry doubles from [`RetryPolicy::initial_backoff`] and is capped by
248/// [`RetryPolicy::max_backoff`]; the actual delay is then drawn anywhere between zero and that
249/// ceiling, so that emitters which failed at the same moment do not come back at the same moment.
250/// Retrying stops as soon as the delays of the send would add up to more than
251/// [`RetryPolicy::max_total_delay`].
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct RetryPolicy {
254    /// Attempts a single send makes at most, the first one included. `1` disables retrying.
255    ///
256    /// Never more than [`MAX_ATTEMPTS_CAP`], and never less than one.
257    pub max_attempts: u32,
258
259    /// Ceiling of the delay before the first retry.
260    pub initial_backoff: StdDuration,
261
262    /// Ceiling no single delay ever exceeds, however many retries were made.
263    pub max_backoff: StdDuration,
264
265    /// Budget all the delays of one send share.
266    pub max_total_delay: StdDuration,
267}
268
269#[cfg(feature = "producer")]
270impl Default for RetryPolicy {
271    /// Four attempts spread over at most five seconds.
272    ///
273    /// Three retries absorb the blips a webhook emitter meets in production (a connection reset, a
274    /// rolling deployment answering 503) without holding the caller's task for long, and the
275    /// five-second budget bounds what the worst send costs whatever the individual delays turn out
276    /// to be.
277    fn default() -> Self {
278        Self {
279            max_attempts: 4,
280            initial_backoff: StdDuration::from_millis(100),
281            max_backoff: StdDuration::from_secs(2),
282            max_total_delay: StdDuration::from_secs(5),
283        }
284    }
285}
286
287#[cfg(feature = "producer")]
288impl RetryPolicy {
289    /// A policy that never retries: one attempt, and the caller hears about whatever it returned.
290    pub const fn disabled() -> Self {
291        Self {
292            max_attempts: 1,
293            initial_backoff: StdDuration::ZERO,
294            max_backoff: StdDuration::ZERO,
295            max_total_delay: StdDuration::ZERO,
296        }
297    }
298
299    /// Attempts this policy actually makes: [`RetryPolicy::max_attempts`], brought back inside
300    /// `1..=`[`MAX_ATTEMPTS_CAP`].
301    pub fn attempts(&self) -> u32 {
302        self.max_attempts.clamp(1, MAX_ATTEMPTS_CAP)
303    }
304
305    /// Ceiling of the delay before retry number `retry`, where `1` is the first retry.
306    ///
307    /// It doubles from [`RetryPolicy::initial_backoff`] and never exceeds
308    /// [`RetryPolicy::max_backoff`], so the ceilings of successive retries never decrease.
309    pub fn backoff_ceiling(&self, retry: u32) -> StdDuration {
310        // 2^31 doublings of any non-zero duration already saturate `max_backoff`, so the exponent
311        // is capped there rather than left to overflow.
312        let doublings = retry.saturating_sub(1).min(u32::BITS - 1);
313        self.initial_backoff
314            .saturating_mul(2u32.saturating_pow(doublings))
315            .min(self.max_backoff)
316    }
317
318    /// The delays this policy waits between the attempts of one send, one per retry, given one
319    /// random draw in `[0, 1)` per retry.
320    ///
321    /// Each delay lands between zero and the ceiling of its retry, and the schedule is cut short as
322    /// soon as the next delay would spend more than [`RetryPolicy::max_total_delay`]. There are
323    /// therefore at most [`RetryPolicy::attempts`]` - 1` delays, and they add up to at most
324    /// `max_total_delay`.
325    ///
326    /// A draw that is missing or is not a finite number is read as `1`, which asks for the whole
327    /// ceiling: an unusable source of randomness makes the client wait longer, never less.
328    pub fn delays(&self, draws: &[f64]) -> Vec<StdDuration> {
329        let retries = self.attempts().saturating_sub(1);
330        let mut delays = Vec::with_capacity(retries as usize);
331        let mut spent = StdDuration::ZERO;
332
333        for retry in 1..=retries {
334            let draw = match draws.get((retry - 1) as usize) {
335                Some(draw) if draw.is_finite() => draw.clamp(0.0, 1.0),
336                _ => 1.0,
337            };
338            let delay = self.backoff_ceiling(retry).mul_f64(draw);
339
340            if spent.saturating_add(delay) > self.max_total_delay {
341                break;
342            }
343            spent = spent.saturating_add(delay);
344            delays.push(delay);
345        }
346
347        delays
348    }
349}
350
351#[cfg(feature = "producer")]
352/// Draws used to jitter the delays of one send.
353///
354/// Jitter only has to keep emitters that failed together from coming back together; it does not
355/// have to be unpredictable. The randomness the standard library seeds its hashers with is enough
356/// for that, and it keeps this client free of a random-number-generator dependency.
357fn jitter_draws(count: usize) -> Vec<f64> {
358    // An `f64` carries 53 bits exactly, so keeping the 53 high bits and dividing by 2^53 lands in
359    // `[0, 1)` without rounding to 1.
360    const KEPT_BITS: u32 = 53;
361
362    (0..count)
363        .map(|_| {
364            let drawn = RandomState::new().build_hasher().finish();
365            (drawn >> (u64::BITS - KEPT_BITS)) as f64 / (1u64 << KEPT_BITS) as f64
366        })
367        .collect()
368}
369
370#[cfg(feature = "producer")]
371/// The Hook0 client
372///
373/// This struct is supposed to be initialized once and shared/reused wherever you need to send events in your app.
374#[derive(Debug, Clone)]
375pub struct Hook0Client {
376    client: Client,
377    api_url: Url,
378    application_id: Uuid,
379    retry_policy: RetryPolicy,
380    request_timeout: StdDuration,
381    max_payload_bytes: usize,
382    max_response_bytes: usize,
383}
384
385#[cfg(feature = "producer")]
386impl Hook0Client {
387    /// Initialize a client
388    ///
389    /// - `api_url` - Base API URL of a Hook0 instance (example: `https://app.hook0.com/api/v1`).
390    /// - `application_id` - UUID of your Hook0 application.
391    /// - `token` - Authentication token valid for your Hook0 application.
392    pub fn new(api_url: Url, application_id: Uuid, token: &str) -> Result<Self, Hook0ClientError> {
393        let authenticated_client = HeaderValue::from_str(&format!("Bearer {token}"))
394            .map_err(|e| Hook0ClientError::AuthHeader(e).log_and_return())
395            .map(|hv| {
396                // A client that asks for nothing is at the mercy of whatever the API decides to
397                // serve the day it serves more than one representation.
398                HeaderMap::from_iter([
399                    (AUTHORIZATION, hv),
400                    (ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE)),
401                ])
402            })
403            .and_then(|headers| {
404                Client::builder()
405                    .default_headers(headers)
406                    // Said once here rather than per request: an instance can otherwise not tell
407                    // which SDKs, at which versions, are still reaching it.
408                    .user_agent(user_agent())
409                    .build()
410                    .map_err(|e| Hook0ClientError::ReqwestClient(e).log_and_return())
411            })?;
412
413        Ok(Self {
414            api_url,
415            client: authenticated_client,
416            application_id,
417            retry_policy: RetryPolicy::default(),
418            request_timeout: DEFAULT_REQUEST_TIMEOUT,
419            max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
420            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
421        })
422    }
423
424    /// Get the API URL of this client
425    pub fn api_url(&self) -> &Url {
426        &self.api_url
427    }
428
429    /// Get the application ID of this client
430    pub fn application_id(&self) -> &Uuid {
431        &self.application_id
432    }
433
434    /// Change how this client retries a send that failed in a way a repetition could fix
435    ///
436    /// Pass [`RetryPolicy::disabled`] to send each event exactly once.
437    pub fn with_retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
438        self.retry_policy = retry_policy;
439        self
440    }
441
442    /// Get the retry policy of this client
443    pub fn retry_policy(&self) -> &RetryPolicy {
444        &self.retry_policy
445    }
446
447    /// Change how long one attempt at reaching Hook0 is given before it is abandoned
448    ///
449    /// The timeout applies to each attempt, not to the send as a whole.
450    pub fn with_request_timeout(mut self, request_timeout: StdDuration) -> Self {
451        self.request_timeout = request_timeout;
452        self
453    }
454
455    /// Get the timeout this client gives one attempt at reaching Hook0
456    pub fn request_timeout(&self) -> StdDuration {
457        self.request_timeout
458    }
459
460    /// Change the largest event payload this client agrees to send
461    ///
462    /// An event whose payload is larger is refused before any request is issued.
463    pub fn with_max_payload_bytes(mut self, max_payload_bytes: usize) -> Self {
464        self.max_payload_bytes = max_payload_bytes;
465        self
466    }
467
468    /// Get the largest event payload this client agrees to send
469    pub fn max_payload_bytes(&self) -> usize {
470        self.max_payload_bytes
471    }
472
473    /// Change the largest answer this client agrees to read off the socket
474    ///
475    /// An answer whose body is larger is abandoned where it crossed the ceiling, and the attempt
476    /// fails rather than holding whatever the other end decided to write.
477    pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
478        self.max_response_bytes = max_response_bytes;
479        self
480    }
481
482    /// Get the largest answer this client agrees to read off the socket
483    pub fn max_response_bytes(&self) -> usize {
484        self.max_response_bytes
485    }
486
487    fn mk_url(&self, segments: &[&str]) -> Result<Url, Hook0ClientError> {
488        append_url_segments(&self.api_url, segments)
489            .map_err(|e| Hook0ClientError::Url(e).log_and_return())
490    }
491
492    /// Send an event to Hook0
493    ///
494    /// The event is sent under an ID this client knows: the one set on the event, or a UUIDv7 this
495    /// client generates when the event carries none. Because Hook0 keys events on that ID, a
496    /// request that is repeated after a network failure or a server error ingests the event once,
497    /// not twice — which is what makes retrying safe.
498    ///
499    /// A send is bounded on five axes, each of them configurable:
500    /// [`Hook0Client::with_max_payload_bytes`] rules an oversized payload out before anything is
501    /// sent, [`Hook0Client::with_request_timeout`] bounds one attempt,
502    /// [`Hook0Client::with_max_response_bytes`] bounds what an answer may cost to read,
503    /// [`RetryPolicy::max_attempts`] bounds how many attempts are made, and
504    /// [`RetryPolicy::max_total_delay`] bounds the time spent waiting between them.
505    ///
506    /// A network failure, a server error and an instance that is pacing its requests are retried;
507    /// anything Hook0 refuses outright — a spent quota included, which no delay this send can
508    /// afford would clear — is reported as is. When the answer names how long to wait before the
509    /// request becomes servable again, that delay is waited out instead of this client's own
510    /// schedule, cut down to what is left of [`RetryPolicy::max_total_delay`].
511    ///
512    /// A retried request that Hook0 answers with `EventAlreadyIngested` reports success: an earlier
513    /// attempt of this very send reached the API, and the event carries the ID returned here. That
514    /// answer to a *first* attempt is a genuine conflict and is reported as an error.
515    pub async fn send_event(&self, event: &Event<'_>) -> Result<Uuid, Hook0ClientError> {
516        let event_ingestion_url = self.mk_url(&["event"])?;
517        let event_id = match event.event_id {
518            Some(event_id) => event_id.to_owned(),
519            None => Uuid::now_v7(),
520        };
521        let full_event = FullEvent::from_event(event, &self.application_id, &event_id);
522        let body = BoundedEvent {
523            event: &full_event,
524            max_payload_bytes: self.max_payload_bytes,
525        };
526
527        let delays = self.retry_policy.delays(&jitter_draws(
528            self.retry_policy.attempts().saturating_sub(1) as usize,
529        ));
530        let mut waited = StdDuration::ZERO;
531        let mut attempts = 0u32;
532
533        loop {
534            attempts += 1;
535            let outcome = self.attempt_event_send(&event_ingestion_url, &body).await;
536
537            let failure = match outcome {
538                Attempt::Ingested(id) => return Ok(id),
539                Attempt::AlreadyIngested { error, body } => {
540                    if attempts > 1 {
541                        debug!(
542                            "Event {event_id} was already ingested by a previous attempt of this send"
543                        );
544                        return Ok(event_id);
545                    }
546                    Failure {
547                        error,
548                        body,
549                        retryable: false,
550                        named_delay: None,
551                    }
552                }
553                Attempt::Failed(failure) => failure,
554            };
555
556            match delays.get((attempts - 1) as usize) {
557                Some(delay) if failure.retryable => {
558                    trace!("Attempt {attempts} at sending event {event_id} failed, retrying");
559                    let wait = wait_before_retry(&self.retry_policy, &failure, *delay, waited);
560                    waited = waited.saturating_add(wait);
561                    tokio::time::sleep(wait).await;
562                }
563                _ => {
564                    return Err(Hook0ClientError::EventSending {
565                        event_id: Some(event_id),
566                        error: failure.error,
567                        body: give_up_reason(attempts, waited, failure.body),
568                    }
569                    .log_and_return());
570                }
571            }
572        }
573    }
574
575    /// Perform one attempt at sending an already-bounded event to Hook0
576    async fn attempt_event_send(&self, url: &Url, body: &BoundedEvent<'_>) -> Attempt {
577        let response = self
578            .client
579            .post(url.as_str())
580            // Per request rather than a default of the underlying client: the policy is chosen
581            // after that client is built, so a default settled at construction would state the
582            // policy this one replaced.
583            .header(CLIENT_OPTIONS, client_options(&self.retry_policy))
584            .timeout(self.request_timeout)
585            .json(body)
586            .send()
587            .await;
588
589        let answer = match response {
590            Ok(res) => res,
591            Err(error) => {
592                return Attempt::Failed(Failure {
593                    retryable: is_transient(&error),
594                    body: underlying_cause(&error),
595                    named_delay: None,
596                    error,
597                });
598            }
599        };
600        let status = answer.status();
601        let named_delay = named_delay(answer.headers());
602        let (res, refusal) = match bounded(answer, self.max_response_bytes).await {
603            Ok(read) => read,
604            // The answer stopped mid-way, so it says nothing about whether Hook0 acted on the
605            // request; the next attempt can carry the whole of it, and the ID this client chose is
606            // what keeps that from ingesting the event twice.
607            Err(error) => {
608                return Attempt::Failed(Failure {
609                    retryable: true,
610                    body: underlying_cause(&error),
611                    named_delay: None,
612                    error,
613                });
614            }
615        };
616
617        match res.error_for_status_ref() {
618            Ok(_) => {
619                #[derive(Debug, Deserialize)]
620                struct Response {
621                    event_id: Uuid,
622                }
623                match res.json::<Response>().await {
624                    Ok(response) => Attempt::Ingested(response.event_id),
625                    // Hook0 accepted the event but answered something this client cannot read — an
626                    // answer above a ceiling it set for itself is one of those; repeating the
627                    // request would meet the same answer.
628                    Err(error) => Attempt::Failed(Failure {
629                        body: refusal.or_else(|| underlying_cause(&error)),
630                        error,
631                        retryable: false,
632                        named_delay: None,
633                    }),
634                }
635            }
636            Err(error) => {
637                let body = res.text().await.ok();
638                if status == StatusCode::CONFLICT && is_already_ingested(body.as_deref()) {
639                    Attempt::AlreadyIngested { error, body }
640                } else {
641                    Attempt::Failed(Failure {
642                        // An answer that crossed a ceiling this client set for itself draws the
643                        // same answer the next time, whatever its status says.
644                        retryable: refusal.is_none() && is_retryable(status, body.as_deref()),
645                        body: refusal.or(body),
646                        error,
647                        named_delay,
648                    })
649                }
650            }
651        }
652    }
653
654    /// Ensure the configured app has the right event types or create them
655    ///
656    /// Returns the list of event types that were created, if any.
657    pub async fn upsert_event_types(
658        &self,
659        event_types: &[&str],
660    ) -> Result<Vec<String>, Hook0ClientError> {
661        let structured_event_types = event_types
662            .iter()
663            .map(|str| {
664                EventType::from_str(str)
665                    .map_err(|_| Hook0ClientError::InvalidEventType(str.to_string()))
666            })
667            .collect::<Result<Vec<EventType>, Hook0ClientError>>()?;
668
669        let event_types_url = self.mk_url(&["event_types"])?;
670        #[derive(Debug, Deserialize)]
671        struct ApiEventType {
672            event_type_name: String,
673        }
674
675        trace!("Getting the list of available event types");
676        let available_event_types_answer = self
677            .client
678            .get(event_types_url.as_str())
679            .header(CLIENT_OPTIONS, client_options(&self.retry_policy))
680            .query(&[("application_id", self.application_id())])
681            .send()
682            .await
683            .map_err(Hook0ClientError::GetAvailableEventTypes)?;
684        let available_event_types_vec =
685            bounded(available_event_types_answer, self.max_response_bytes)
686                .await
687                .map_err(Hook0ClientError::GetAvailableEventTypes)?
688                .0
689                .error_for_status()
690                .map_err(Hook0ClientError::GetAvailableEventTypes)?
691                .json::<Vec<ApiEventType>>()
692                .await
693                .map_err(Hook0ClientError::GetAvailableEventTypes)?;
694        let available_event_types = available_event_types_vec
695            .iter()
696            .map(|et| et.event_type_name.to_owned())
697            .collect::<HashSet<String>>();
698        debug!(
699            "There are currently {} event types",
700            available_event_types.len(),
701        );
702
703        #[derive(Debug, Serialize)]
704        struct ApiEventTypePost {
705            application_id: Uuid,
706            service: String,
707            resource_type: String,
708            verb: String,
709        }
710        impl Display for ApiEventTypePost {
711            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
712                write!(f, "{}.{}.{}", self.service, self.resource_type, self.verb)
713            }
714        }
715
716        let mut added_event_types = vec![];
717        for event_type in structured_event_types {
718            let event_type_str = event_type.to_string();
719            if !available_event_types.contains(&event_type_str) {
720                debug!("Creating the '{event_type}' event type");
721
722                let body = ApiEventTypePost {
723                    application_id: self.application_id,
724                    service: event_type.service,
725                    resource_type: event_type.resource_type,
726                    verb: event_type.verb,
727                };
728
729                self.client
730                    .post(event_types_url.as_str())
731                    .header(CLIENT_OPTIONS, client_options(&self.retry_policy))
732                    .json(&body)
733                    .send()
734                    .await
735                    .map_err(|e| Hook0ClientError::CreatingEventType {
736                        event_type_name: body.to_string(),
737                        error: e,
738                    })?
739                    .error_for_status()
740                    .map_err(|e| Hook0ClientError::CreatingEventType {
741                        event_type_name: body.to_string(),
742                        error: e,
743                    })?;
744
745                added_event_types.push(body.to_string());
746            }
747        }
748        debug!("{} new event types were created", added_event_types.len());
749
750        Ok(added_event_types)
751    }
752}
753
754#[cfg(feature = "consumer")]
755/// Verifies the signature of a webhook
756///
757/// - `signature` - The value of the `X-Hook0-Signature` header.
758/// - `payload` - The raw body of the webhook request.
759/// - `headers` - Headers of the webhook request.
760/// - `subscription_secret` - The signing secret used to validate the signature.
761/// - `tolerance` - The maximum allowed time difference for the timestamp, in either direction (5 minutes is a good trade-off between flexibility and protecting against replay attacks). A timestamp that is too far in the future is rejected just like one that is too far in the past, so that the acceptance window of any given webhook stays bounded.
762/// - `current_time` - The current time (used to check the timestamp).
763pub fn verify_webhook_signature_with_current_time<
764    HeaderKey: AsRef<[u8]>,
765    HeaderValue: AsRef<[u8]>,
766>(
767    signature: &str,
768    payload: &[u8],
769    headers: &[(HeaderKey, HeaderValue)],
770    subscription_secret: &str,
771    tolerance: StdDuration,
772    current_time: DateTime<Utc>,
773) -> Result<(), Hook0ClientError> {
774    let parsed_sig =
775        signature::Signature::parse(signature).map_err(|_| Hook0ClientError::InvalidSignature)?;
776
777    let headers_with_parsed_name = headers
778        .iter()
779        .map(|(k, v)| {
780            let name = http::HeaderName::from_bytes(k.as_ref()).map_err(|error| {
781                Hook0ClientError::InvalidHeaderName {
782                    header_name: String::from_utf8_lossy(k.as_ref()).into_owned(),
783                    error,
784                }
785            });
786            name.map(|n| (n, v))
787        })
788        .collect::<Result<std::collections::HashMap<_, _>, _>>()?;
789    let headers_vec = parsed_sig
790        .h
791        .iter()
792        .map(|expected| {
793            headers_with_parsed_name
794                .get(expected)
795                .ok_or_else(|| Hook0ClientError::MissingHeader(expected.to_owned()))
796                .and_then(|v| {
797                    String::from_utf8(v.as_ref().to_vec()).map_err(|error| {
798                        Hook0ClientError::InvalidHeaderValue {
799                            header_name: expected.to_owned(),
800                            header_value: String::from_utf8_lossy(v.as_ref()).into_owned(),
801                            error,
802                        }
803                    })
804                })
805        })
806        .collect::<Result<Vec<_>, _>>()?;
807
808    if !parsed_sig.verify(payload, &headers_vec, subscription_secret)? {
809        Err(Hook0ClientError::InvalidSignature)
810    } else {
811        let signed_at = DateTime::from_timestamp(parsed_sig.timestamp, 0);
812
813        match signed_at {
814            Some(signed_at) => {
815                let tolerance = Duration::from_std(tolerance);
816                match tolerance {
817                    Ok(tolerance) => {
818                        if (current_time - signed_at).abs() > tolerance {
819                            Err(Hook0ClientError::ExpiredWebhook {
820                                signed_at,
821                                tolerance,
822                                current_time,
823                            })
824                        } else {
825                            Ok(())
826                        }
827                    }
828                    Err(e) => Err(Hook0ClientError::InvalidTolerance(e)),
829                }
830            }
831            None => Err(Hook0ClientError::InvalidSignature),
832        }
833    }
834}
835
836#[cfg(feature = "consumer")]
837/// Verifies the signature of a webhook
838///
839/// - `signature` - The value of the `X-Hook0-Signature` header.
840/// - `payload` - The raw body of the webhook request.
841/// - `headers` - Headers of the webhook request.
842/// - `subscription_secret` - The signing secret used to validate the signature.
843/// - `tolerance` - The maximum allowed time difference for the timestamp, in either direction (5 minutes is a good trade-off between flexibility and protecting against replay attacks). A timestamp that is too far in the future is rejected just like one that is too far in the past, so that the acceptance window of any given webhook stays bounded.
844pub fn verify_webhook_signature<HeaderKey: AsRef<[u8]>, HeaderValue: AsRef<[u8]>>(
845    signature: &str,
846    payload: &[u8],
847    headers: &[(HeaderKey, HeaderValue)],
848    subscription_secret: &str,
849    tolerance: StdDuration,
850) -> Result<(), Hook0ClientError> {
851    verify_webhook_signature_with_current_time(
852        signature,
853        payload,
854        headers,
855        subscription_secret,
856        tolerance,
857        Utc::now(),
858    )
859}
860
861#[cfg(feature = "producer")]
862/// A structured event type
863#[derive(Debug, Serialize, PartialEq, Eq)]
864struct EventType {
865    service: String,
866    resource_type: String,
867    verb: String,
868}
869
870#[cfg(feature = "producer")]
871impl FromStr for EventType {
872    type Err = ();
873
874    fn from_str(s: &str) -> Result<Self, Self::Err> {
875        let captures = regex_captures!("^([A-Z0-9_]+)[.]([A-Z0-9_]+)[.]([A-Z0-9_]+)$"i, s);
876        if let Some((_, service, resource_type, verb)) = captures {
877            Ok(Self {
878                resource_type: resource_type.to_owned(),
879                service: service.to_owned(),
880                verb: verb.to_owned(),
881            })
882        } else {
883            Err(())
884        }
885    }
886}
887
888#[cfg(feature = "producer")]
889impl Display for EventType {
890    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
891        write!(f, "{}.{}.{}", self.service, self.resource_type, self.verb)
892    }
893}
894
895#[cfg(feature = "producer")]
896/// An event that can be sent to Hook0
897#[derive(Debug, Clone, PartialEq, Eq)]
898pub struct Event<'a> {
899    /// Unique ID of the event (the client generates a UUIDv7 if nothing is provided)
900    ///
901    /// Providing nothing no longer means the ID comes from Hook0: [`Hook0Client::send_event`]
902    /// generates it, sends it, and returns it. That is what lets it retry a request without
903    /// risking a second copy of the event being ingested and delivered to every subscriber.
904    pub event_id: Option<&'a Uuid>,
905    /// Type of the event (as configured in your Hook0 application)
906    pub event_type: &'a str,
907    /// Payload
908    pub payload: Cow<'a, str>,
909    /// Content type of the payload
910    pub payload_content_type: &'a str,
911    /// Optional key-value metadata
912    pub metadata: Option<Vec<(String, String)>>,
913    /// Datetime of when the event occurred (current time will be used if nothing is provided)
914    pub occurred_at: Option<DateTime<Utc>>,
915    /// Labels that Hook0 will use to route the event
916    pub labels: Vec<(String, String)>,
917}
918
919#[cfg(feature = "producer")]
920#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
921struct FullEvent<'a> {
922    pub application_id: Uuid,
923    pub event_id: &'a Uuid,
924    pub event_type: &'a str,
925    pub payload: &'a str,
926    pub payload_content_type: &'a str,
927    pub metadata: Option<HashMap<String, String>>,
928    pub occurred_at: DateTime<Utc>,
929    pub labels: HashMap<String, String>,
930}
931
932#[cfg(feature = "producer")]
933impl<'a> FullEvent<'a> {
934    pub fn from_event(event: &'a Event, application_id: &Uuid, event_id: &'a Uuid) -> Self {
935        let occurred_at = event.occurred_at.unwrap_or_else(Utc::now);
936
937        Self {
938            application_id: application_id.to_owned(),
939            event_id,
940            event_type: event.event_type,
941            payload: event.payload.as_ref(),
942            payload_content_type: event.payload_content_type,
943            metadata: event
944                .metadata
945                .as_ref()
946                .map(|items| HashMap::from_iter(items.iter().cloned())),
947            occurred_at,
948            labels: HashMap::from_iter(event.labels.iter().cloned()),
949        }
950    }
951}
952
953#[cfg(feature = "producer")]
954/// An event that refuses, while it is being serialized, a payload larger than the client accepts.
955///
956/// The refusal has to happen here rather than in a check of its own: `reqwest` turns a
957/// serialization failure into a builder error that it returns from `send` without opening a socket,
958/// and that error is the only shape a refusal can take. [`Hook0ClientError::EventSending`] is the
959/// one error the send path reports, and the `reqwest::Error` it carries can only be built by
960/// `reqwest` itself.
961struct BoundedEvent<'a> {
962    event: &'a FullEvent<'a>,
963    max_payload_bytes: usize,
964}
965
966#[cfg(feature = "producer")]
967impl Serialize for BoundedEvent<'_> {
968    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
969        let size = self.event.payload.len();
970        if size > self.max_payload_bytes {
971            return Err(S::Error::custom(format!(
972                "event payload is {size} bytes, which is more than the {} bytes this client sends at most; nothing was sent",
973                self.max_payload_bytes
974            )));
975        }
976
977        self.event.serialize(serializer)
978    }
979}
980
981#[cfg(feature = "producer")]
982/// What one attempt at sending an event ended with.
983enum Attempt {
984    /// Hook0 ingested the event and answered with its ID.
985    Ingested(Uuid),
986
987    /// Hook0 refused the event because it already holds one under the same ID.
988    AlreadyIngested {
989        /// Error as reported by Reqwest for the conflict status
990        error: reqwest::Error,
991
992        /// Body of the HTTP response
993        body: Option<String>,
994    },
995
996    /// The attempt did not ingest anything.
997    Failed(Failure),
998}
999
1000#[cfg(feature = "producer")]
1001/// A failed attempt, and whether repeating it could end differently.
1002struct Failure {
1003    error: reqwest::Error,
1004    body: Option<String>,
1005    retryable: bool,
1006
1007    /// How long Hook0 named before the request becomes servable again, when it named a delay this
1008    /// client can read.
1009    named_delay: Option<StdDuration>,
1010}
1011
1012#[cfg(feature = "producer")]
1013/// The same answer, read no further than this client agrees to hold, and why it was not read at all
1014/// when its head had already crossed a ceiling.
1015///
1016/// `reqwest` reads a body without a ceiling of its own, so a server that is broken or hostile can
1017/// stream into an emitter's memory for as long as the connection lasts. The body is taken a frame
1018/// at a time and abandoned as soon as the next one would cross the ceiling, so nothing beyond it is
1019/// ever held: what comes back carries the bytes that were read, and the caller reads them the way it
1020/// would have read the answer itself.
1021///
1022/// An answer that crossed a ceiling — of its head, or of its body — comes back carrying nothing at
1023/// all, which is what makes the read that follows fail. That is how the refusal reaches a caller:
1024/// [`Hook0ClientError::EventSending`] carries a Reqwest error, and only Reqwest builds one.
1025async fn bounded(
1026    mut answer: reqwest::Response,
1027    max_response_bytes: usize,
1028) -> Result<(reqwest::Response, Option<String>), reqwest::Error> {
1029    let mut refusal = head_above_a_bound(answer.headers());
1030    let mut read: Vec<u8> = Vec::new();
1031
1032    if refusal.is_none() {
1033        while let Some(frame) = answer.chunk().await? {
1034            if read.len().saturating_add(frame.len()) > max_response_bytes {
1035                read = Vec::new();
1036                refusal = Some(format!(
1037                    "the API answered more than the {max_response_bytes} bytes read at most"
1038                ));
1039                break;
1040            }
1041            read.extend_from_slice(&frame);
1042        }
1043    }
1044
1045    let url = answer.url().to_owned();
1046    // Whatever is left of the body is dropped with the answer it came on, read or refused.
1047    let (mut parts, _) = http::Response::<reqwest::Body>::from(answer).into_parts();
1048
1049    // Rebuilding an answer drops the URL it came from, and `reqwest` reads that URL back out of an
1050    // extension only its own builder writes; without this, every failure below names a placeholder
1051    // instead of the endpoint it was answered by.
1052    if let Ok(carrier) = http::Response::builder().url(url).body(()) {
1053        parts.extensions = carrier.into_parts().0.extensions;
1054    }
1055
1056    let held =
1057        reqwest::Response::from(http::Response::from_parts(parts, reqwest::Body::from(read)));
1058    Ok((held, refusal))
1059}
1060
1061#[cfg(feature = "producer")]
1062/// Why the head of an answer is above what this client agrees to hold, when it is.
1063///
1064/// Counted the way a head is written: one line per header, its name and its value together. The
1065/// line count and the length of one line refuse early, on the line that crosses them; the whole
1066/// head is what actually bounds the memory a head can cost, since the other two multiply.
1067fn head_above_a_bound(headers: &HeaderMap) -> Option<String> {
1068    let mut lines = 0usize;
1069    let mut whole = 0usize;
1070
1071    for (name, value) in headers {
1072        lines += 1;
1073        if lines > MAX_RESPONSE_HEADERS {
1074            return Some(format!(
1075                "the API answered more than the {MAX_RESPONSE_HEADERS} header lines read at most"
1076            ));
1077        }
1078
1079        let line = name.as_str().len().saturating_add(value.len());
1080        if line > MAX_HEADER_BYTES {
1081            return Some(format!(
1082                "the API answered a `{name}` header above the {MAX_HEADER_BYTES} bytes read at most"
1083            ));
1084        }
1085
1086        whole = whole.saturating_add(line);
1087        if whole > MAX_HEAD_BYTES {
1088            return Some(format!(
1089                "the API answered a head above the {MAX_HEAD_BYTES} bytes read at most"
1090            ));
1091        }
1092    }
1093
1094    None
1095}
1096
1097#[cfg(feature = "producer")]
1098/// Whether repeating a request Hook0 answered that way could end differently.
1099///
1100/// The status decides on its own everywhere but under the one Hook0 answers both a spent quota and
1101/// a paced instance with: a quota clears when a plan changes or a day turns, and neither is
1102/// something a send spending seconds can wait for. Only the problem the body names tells the two
1103/// apart, and a body naming a problem this client has never heard of falls back to the status.
1104fn is_retryable(status: StatusCode, body: Option<&str>) -> bool {
1105    if status == StatusCode::TOO_MANY_REQUESTS {
1106        return problem_id(body).as_deref() == Some(RATE_LIMITED);
1107    }
1108    // Only the server side of a 5xx can change between two identical requests.
1109    status.is_server_error()
1110}
1111
1112#[cfg(feature = "producer")]
1113/// The delay Hook0 named before the request becomes servable again, when it named one this client
1114/// can read.
1115///
1116/// Only a whole number of seconds is read. The header may also carry a date, which is a clock this
1117/// client would be comparing against its own, and anything else is a header nobody meant: both
1118/// leave the client's own schedule in place rather than being guessed at.
1119fn named_delay(headers: &HeaderMap) -> Option<StdDuration> {
1120    headers
1121        .get(RETRY_AFTER)
1122        .and_then(|named| named.to_str().ok())
1123        .and_then(|named| named.trim().parse::<u32>().ok())
1124        .map(|seconds| StdDuration::from_secs(u64::from(seconds)))
1125}
1126
1127#[cfg(feature = "producer")]
1128/// How long to wait before the next attempt: what Hook0 asked for when it asked for anything, and
1129/// this client's own schedule otherwise.
1130///
1131/// Either way it is cut down to what is left of the budget the delays of one send share, so a delay
1132/// written by the other end cannot stretch a send past what its caller allowed for it.
1133fn wait_before_retry(
1134    policy: &RetryPolicy,
1135    failure: &Failure,
1136    scheduled: StdDuration,
1137    waited: StdDuration,
1138) -> StdDuration {
1139    let remaining = policy.max_total_delay.saturating_sub(waited);
1140    failure.named_delay.unwrap_or(scheduled).min(remaining)
1141}
1142
1143#[cfg(feature = "producer")]
1144/// Whether a Reqwest error comes from the transport rather than from what Hook0 answered.
1145///
1146/// These are the failures an identical request can survive: a connection that was refused or
1147/// reset, an attempt that ran out of time, a response whose body stopped mid-way. None of them
1148/// says whether Hook0 ingested the event, which is precisely why the client sends an ID it chose
1149/// itself.
1150fn is_transient(error: &reqwest::Error) -> bool {
1151    error.is_timeout() || error.is_connect() || error.is_request() || error.is_body()
1152}
1153
1154#[cfg(feature = "producer")]
1155/// What a Reqwest error carries under its own summary.
1156///
1157/// `reqwest::Error` renders as its kind and nothing else: the refusal
1158/// [`BoundedEvent`] raises while the event is being serialized reads as `builder error`, and a
1159/// connection that was reset reads as `error sending request`. What names the actual cause is the
1160/// chain underneath, which is what a caller needs to see.
1161fn underlying_cause(error: &reqwest::Error) -> Option<String> {
1162    /// No error chain a client meets is anywhere near this long; the bound keeps a cyclic one from
1163    /// being walked forever.
1164    const MAX_LINKS: usize = 8;
1165
1166    let mut causes = Vec::new();
1167    let mut cause = std::error::Error::source(error);
1168    while let Some(current) = cause {
1169        if causes.len() >= MAX_LINKS {
1170            break;
1171        }
1172        causes.push(current.to_string());
1173        cause = current.source();
1174    }
1175
1176    if causes.is_empty() {
1177        None
1178    } else {
1179        Some(causes.join(": "))
1180    }
1181}
1182
1183#[cfg(feature = "producer")]
1184/// The problem an RFC 9457 body names, when it names one this client can read.
1185fn problem_id(body: Option<&str>) -> Option<String> {
1186    #[derive(Debug, Deserialize)]
1187    struct Problem {
1188        id: String,
1189    }
1190
1191    body.and_then(|body| serde_json::from_str::<Problem>(body).ok())
1192        .map(|problem| problem.id)
1193}
1194
1195#[cfg(feature = "producer")]
1196/// Whether an RFC 9457 problem body is the one Hook0 answers when an event ID is already taken.
1197///
1198/// The body names the problem but does not carry the event ID, which is the other reason the
1199/// client has to know the ID it sent.
1200fn is_already_ingested(body: Option<&str>) -> bool {
1201    problem_id(body).as_deref() == Some(ALREADY_INGESTED)
1202}
1203
1204#[cfg(feature = "producer")]
1205/// What to report as the body of a send that is being given up on.
1206///
1207/// A send that never retried reports what Hook0 answered, unchanged. A send that did retry reports
1208/// that it ran out of attempts, and how much of its delay budget it spent doing so — without that,
1209/// an exhausted send and a single refused request are indistinguishable to the caller.
1210fn give_up_reason(attempts: u32, waited: StdDuration, body: Option<String>) -> Option<String> {
1211    if attempts <= 1 {
1212        return body;
1213    }
1214
1215    let answer = match body {
1216        Some(body) => format!("; last response body: {body}"),
1217        None => String::new(),
1218    };
1219    Some(format!(
1220        "gave up after {attempts} attempts spread over {waited:?} of retry delay{answer}"
1221    ))
1222}
1223
1224/// Every error Hook0 client can encounter
1225#[derive(Debug, thiserror::Error)]
1226pub enum Hook0ClientError {
1227    #[cfg(feature = "producer")]
1228    /// Cannot build a structurally-valid `Authorization` header
1229    ///
1230    /// _This is an internal error that is unlikely to happen._
1231    #[error("Could not build auth header: {0}")]
1232    AuthHeader(InvalidHeaderValue),
1233
1234    #[cfg(feature = "producer")]
1235    /// Cannot build a Reqwest HTTP client
1236    ///
1237    /// _This is an internal error that is unlikely to happen._
1238    #[error("Could not build reqwest HTTP client: {0}")]
1239    ReqwestClient(reqwest::Error),
1240
1241    #[cfg(feature = "producer")]
1242    /// Cannot build a structurally-valid endpoint URL
1243    ///
1244    /// _This is an internal error that is unlikely to happen._
1245    #[error("Could not create a valid URL to request Hook0's API: {0}")]
1246    Url(ParseError),
1247
1248    #[cfg(feature = "producer")]
1249    /// Something went wrong when sending an event to Hook0
1250    #[error("Sending event{} failed: {error} [body={}]", event_id.map(|id| format!(" {id}")).unwrap_or_else(String::new), body.as_deref().unwrap_or(""))]
1251    EventSending {
1252        /// ID of the event
1253        ///
1254        /// Always the ID the request was sent under, whether the caller chose it or the client
1255        /// generated it.
1256        event_id: Option<Uuid>,
1257
1258        /// Error as reported by Reqwest
1259        ///
1260        /// For a send that was retried, this is what the last attempt ran into.
1261        error: reqwest::Error,
1262
1263        /// Body of the HTTP response, or why the client gave up
1264        ///
1265        /// A send that was retried until it ran out of attempts or of delay budget says so here,
1266        /// along with the body of the last response it got.
1267        body: Option<String>,
1268    },
1269
1270    #[cfg(feature = "producer")]
1271    /// Provided event type does not have a valid syntax
1272    #[error("Provided event type '{0}' does not have a valid syntax (service.resource_type.verb)")]
1273    InvalidEventType(String),
1274
1275    #[cfg(feature = "producer")]
1276    /// Something went wrong when trying to fetch the list of available event types
1277    #[error("Getting available event types failed: {0}")]
1278    GetAvailableEventTypes(reqwest::Error),
1279
1280    #[cfg(feature = "producer")]
1281    /// Something went wrong when creating an event type
1282    #[error("Creating event type '{event_type_name}' failed: {error}")]
1283    CreatingEventType {
1284        /// Name of the event type
1285        event_type_name: String,
1286
1287        /// Error as reported by Reqwest
1288        error: reqwest::Error,
1289    },
1290
1291    #[cfg(feature = "consumer")]
1292    /// The webhook signature is invalid
1293    #[error("Invalid signature")]
1294    InvalidSignature,
1295
1296    #[cfg(feature = "consumer")]
1297    /// The webhook's signature timestamp is outside the tolerance window
1298    ///
1299    /// This covers both a webhook that was signed too long ago (a replay) and one that was signed too far in the future (a clock that is ahead, or a forged timestamp meant to widen the acceptance window).
1300    #[error(
1301        "The webhook's signature timestamp is outside the tolerance window (signed_at={signed_at}, tolerance={tolerance}, current_time={current_time})"
1302    )]
1303    ExpiredWebhook {
1304        /// Timestamp of the moment the webhook was signed
1305        signed_at: DateTime<Utc>,
1306
1307        /// Maximum difference, in either direction, between the signature timestamp and the current time for the webhook to be considered valid
1308        tolerance: Duration,
1309
1310        /// Current time
1311        current_time: DateTime<Utc>,
1312    },
1313
1314    #[cfg(feature = "consumer")]
1315    /// Could not parse signature header
1316    #[error("Could not parse signature header: {0}")]
1317    SignatureHeaderParsing(String),
1318
1319    #[cfg(feature = "consumer")]
1320    /// Could not parse timestamp in signature
1321    #[error("Could not parse timestamp `{timestamp}` in signature: {error}")]
1322    TimestampParsing {
1323        /// Invalid timestamp value
1324        timestamp: String,
1325
1326        /// Timestamp parsing error
1327        error: std::num::ParseIntError,
1328    },
1329
1330    #[cfg(feature = "consumer")]
1331    /// Could not parse v0 signature
1332    #[error("Could not parse v0 signature `{signature}`: {error}")]
1333    V0SignatureParsing {
1334        /// Invalid signature value
1335        signature: String,
1336
1337        /// Signature parsing error
1338        error: hex::FromHexError,
1339    },
1340
1341    #[cfg(feature = "consumer")]
1342    /// Could not parse header names (`h` field)
1343    #[error("Could not parse header name `{header}` in `h` field: {error}")]
1344    HeaderNameParsing {
1345        /// Invalid header name
1346        header: String,
1347
1348        /// Header name parsing error
1349        error: http::header::InvalidHeaderName,
1350    },
1351
1352    #[cfg(feature = "consumer")]
1353    /// Could not parse v1 signature
1354    #[error("Could not parse v1 signature `{signature}`: {error}")]
1355    V1SignatureParsing {
1356        /// Invalid signature value
1357        signature: String,
1358
1359        /// Signature parsing error
1360        error: hex::FromHexError,
1361    },
1362
1363    #[cfg(feature = "consumer")]
1364    /// A header present in the webhook's signature was not provided with a value
1365    #[error("The `{0}` header present in the webhook's signature was not provided with a value")]
1366    MissingHeader(http::HeaderName),
1367
1368    #[cfg(feature = "consumer")]
1369    /// Provided header has an invalid name
1370    #[error("Provided `{header_name}` has an invalid header name: {error}")]
1371    InvalidHeaderName {
1372        /// Invalid header name
1373        header_name: String,
1374
1375        /// Header name parsing error
1376        error: http::header::InvalidHeaderName,
1377    },
1378
1379    #[cfg(feature = "consumer")]
1380    /// Provided header has an invalid value
1381    #[error("Provided `{header_name}` has an invalid header value `{header_value}`: {error}")]
1382    InvalidHeaderValue {
1383        /// Header name
1384        header_name: http::HeaderName,
1385
1386        /// Invalid header value
1387        header_value: String,
1388
1389        /// Header value parsing error
1390        error: std::string::FromUtf8Error,
1391    },
1392
1393    #[cfg(feature = "consumer")]
1394    /// Invalid tolerance Duration
1395    #[error("Invalid tolerance Duration: {0}")]
1396    InvalidTolerance(OutOfRangeError),
1397}
1398
1399#[cfg(feature = "producer")]
1400impl Hook0ClientError {
1401    /// Log the error (using the tracing crate) and return it as a result of this function's call
1402    pub fn log_and_return(self) -> Self {
1403        error!("{self}");
1404        self
1405    }
1406}
1407
1408#[cfg(feature = "producer")]
1409fn append_url_segments(base_url: &Url, segments: &[&str]) -> Result<Url, url::ParseError> {
1410    const SEP: &str = "/";
1411    let segments_str = segments.join(SEP);
1412
1413    let url = Url::parse(&format!("{base_url}/{segments_str}").replace("//", "/"))?;
1414
1415    Ok(url)
1416}