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}