Skip to content

Latest commit

 

History

History
262 lines (204 loc) · 11.3 KB

README.md

File metadata and controls

262 lines (204 loc) · 11.3 KB

honk-honk (Rust)

CI crates.io docs.rs

Official Rust client for Honk! Honk!, the inbox that turns events from your apps, jobs, scripts and CI into calm, grouped push notifications on your phone.

use honk::Honk;

#[tokio::main]
async fn main() -> honk::Result<()> {
    let honk = Honk::from_env()?;

    honk.uhoh("kitchen/oven", "The cake is burning", "Oven at 260 °C and climbing").await?;
    // someone opens a window, someone else finds the oven gloves
    honk.phew("kitchen/oven", "Cake saved", "Nobody needs to know.").await?;
    Ok(())
}

uhoh opens an incident for kitchen/oven and phew closes it: one group with a happy ending on your phone, not two unrelated alarms.

  • Async-first on Tokio, reqwest with rustls (no OpenSSL). A synchronous client with the blocking feature.
  • Retries with backoff, Retry-After, a total deadline and an idempotency key on every send, so a retry never creates a duplicate.
  • Rust 1.85+. #![forbid(unsafe_code)].

Keep the key on the server. An ingestion key (honk_…) lets anyone post into your project. Use this crate in services, jobs, CLIs and CI only; never ship it inside a desktop, mobile or WebAssembly front end. Read it from the environment.

Install

cargo add honk-honk
cargo add tokio --features macros,rt-multi-thread   # if you don't have a runtime yet

or, for synchronous code, cargo add honk-honk --features blocking. The package is honk-honk on crates.io (honk was taken); in code the library is plain honk: use honk::Honk;.

Create a project and an ingestion key at honk-honk.app. Its Integrations page generates ready-to-paste code.

Quick start

use honk::Honk;

#[tokio::main]
async fn main() -> honk::Result<()> {
    let honk = Honk::from_env()?; // HONK_URL, HONK_KEY (+ HONK_SOURCE, HONK_ENVIRONMENT, HONK_CHANNEL)
    honk.beep("Backup finished", "nightly pg_dump took 42 s").await?;
    Ok(())
}

Or explicitly: Honk::new("https://honk-honk.app", key)?. A missing or malformed URL or key is Error::InvalidConfiguration. Honk is cheap to clone: create one per application and share it, so connections are kept alive.

The Honk scale

Every severity has a horn name. Use either; the SDK always sends the canonical value.

Horn Severity Helper Constant
light honk light (info) honk.light(title, message) Severity::LIGHT (= Severity::Info)
beep-beep beep (success) honk.beep(…) Severity::BEEP
loud honk loud (warning) honk.loud(…) Severity::LOUD
long honk long (error) honk.long(…) Severity::LONG
blast blast (critical) honk.blast(…) Severity::BLAST

"loud".parse::<Severity>(), "LOUD" and "warning" are the same event (also for idempotency). long and blast push at least as high priority. info(), success(), warning(), error() and critical() remain as synonyms.

Every helper returns a PendingSend: chain more fields, then .await it.

use honk::Priority;

honk.loud("Disk 91% full", "db-1 /var is at 91%")
    .group_key("disk/db-1/var")
    .priority(Priority::High)
    .metadata("used_percent", 91)
    .await?;

Recipe: notify me when a customer asks for something

Give each request its own group (requests/<id>) and its own idempotency key. Two different customers never fold into one notification, and a retried webhook or job never buzzes twice.

use honk::{Category, Message, Priority, Severity};

let msg = Message::new(format!("{} asked for a quote: {}", req.name, req.summary))
    .title("New quote request")
    .severity(Severity::LOUD)
    .priority(Priority::High)
    .category(Category::Customers)
    .group_key(format!("requests/{}", req.id))
    .url(format!("https://shop.example.com/admin/requests/{}", req.id)); // https only, "Open link"
honk.honk(&msg, format!("request-{}", req.id).as_str()).await?;

Uh-oh and phew: problems and recoveries

uhoh reports a problem and opens an incident for its group key; phew reports the recovery and closes it.

honk.uhoh("db/backup", "Backup failed", "pg_dump exited with 1").await?;
honk.phew("db/backup", "Backup OK", "pg_dump finished").await?;

uhoh is a long honk (error) by default and phew a beep (success); chain .severity(…) to change it. send, problem and recovery still work: they are honk, uhoh and phew under their 0.2 names, with no deprecation warning.

Sending a message

honk(&message, idempotency_key) returns Accepted { id, duplicate, received_at } once Honk! Honk! has durably stored the event (202); that does not mean a push was delivered.

Field Type Notes
message String required, 1–8192 bytes of UTF-8
title Option<String> ≤ 160 characters, one line
severity Option<Severity> default info
priority Option<Priority> Low, Normal, High, Urgent (urgent needs a key with allow_urgent)
category Option<Category> Infrastructure, Security, Backups, Deployments, Payments, Customers, Sales, Automation, Personal, Other
source, environment, channel Option<String> ≤ 64 / 32 / 64; fall back to the client's defaults
group_key Option<String> ≤ 128; same key, same group
event_type Option<EventType> Event, Problem, Recovery (recovery needs a group key)
occurred_at Option<SystemTime> sent as RFC 3339
url, image_url Option<String> https only, no credentials, ≤ 2048 bytes; no fragment in image_url
actions Vec<Action> up to 3 buttons, .action(title, url); see Actions
metadata BTreeMap<String, MetadataValue> ≤ 16 keys [A-Za-z0-9_.-]{1,64}; strings (≤ 512), numbers, booleans
ttl_seconds Option<u32> 60–86400
source_sequence Option<u64> monotonic per source; needs a group key

idempotency_key is your stable key ("order-42-failed", 1–128 printable ASCII characters) or None for a new UUIDv7. A replay within 24 hours returns the original id with duplicate: true; the same key with a different payload is Error::Conflict.

Actions

Up to three buttons on a message, in display order (the first is the primary). Honk! Honk! never opens them; the phone does when you tap one: Mail for mailto:, the Phone app for tel:, Messages for sms:, Safari for https://.

honk.light("New quote request", "Maria Tudor asked for a quote: 3 rooms, 2 bathrooms")
    .group_key("requests/4812")
    .action("Reply", "mailto:maria@example.com?subject=Your%20quote")
    .action("Call", "tel:+15550134")
    .await?;

Message::action works the same way, and message.actions is a plain Vec<Action>. A title is 1–40 characters on one line, shown as sent. A URL is at most 2048 bytes without spaces: https:// (no credentials), mailto: with one address and an optional ?subject=…&body=…, tel: with a number, or sms: with a number and an optional ?body=…. Anything else (http:, javascript:, app schemes) is Error::Validation on actions[i].url.

Configuration

use std::time::Duration;

let honk = Honk::builder()
    .url("https://honk-honk.app")
    .key(std::env::var("HONK_KEY").unwrap_or_default())
    .timeout(Duration::from_secs(5))   // per attempt
    .retries(4)                        // after the first attempt; 0 disables
    .deadline(Duration::from_secs(30)) // total, waits included
    .source("billing")                 // defaults for source, environment, channel
    .environment("production")
    .validate(true)                    // false: leave value checks to the server
    .user_agent("my-app/1.2")
    .build()?;

Retries happen only on network errors, timeouts, 429 and 5xx: exponential backoff with full jitter (random(0, min(8 s, 0.5 s·2ⁿ))), never sooner than Retry-After, all under the deadline. A wait that would cross the deadline (a daily quota) fails fast with retry_after. Redirects are reported, never followed.

Errors

Variant When What to do
Error::Validation rejected locally (local == true) or 400/413/415/422; fields lists every problem fix the message
Error::Auth 401 invalid_key, 403 priority_not_allowed, project_suspended, workspace_suspended fix the key or the priority
Error::Quota 429 quota_exceeded (daily, resets at UTC midnight), rate_limited or overloaded, after retries retry after retry_after
Error::Conflict 409 idempotency_conflict: same key, different payload use a new key, or send the original payload
Error::Network unreachable on every attempt retry later, same key
Error::Timeout attempts timed out; the message may or may not be stored retry later, same key
Error::Server 5xx on every attempt retry later, same key
Error::Http 404 (wrong URL), a redirect, an answer without an id fix the URL
Error::InvalidConfiguration a missing or malformed URL or key fix the configuration

Each variant except the last carries a Failure with status, code, message, fields, local, request_id, idempotency_key, attempts and retry_after.

use honk::Error;

match honk.honk(&msg, "order-1042-failed").await {
    Ok(_) => {}
    Err(Error::Validation(f)) => tracing::error!(?f.fields, "a bug: don't retry"),
    Err(e) if e.is_retryable() => queue.retry_later(e.idempotency_key(), e.retry_after()),
    Err(e) => return Err(e.into()),
}

Blocking client

// Cargo.toml: honk-honk = { version = "0.4", features = ["blocking"] }
use honk::Message;
use honk::blocking::Honk;

let honk = Honk::from_env()?;
honk.beep("Backup finished", "nightly pg_dump took 42 s").send()?;
honk.uhoh("db/backup", "Backup failed", "pg_dump exited with 1").send()?;
honk.honk(&Message::new("Imported 1 204 rows"), None)?;

It runs the async client on a private single-threaded Tokio runtime, with the same retries and guarantees. Don't call it from inside an async runtime; use the async client there.

Development

cargo test                                   # unit tests + mock server
cargo test --all-features                    # also the blocking client
cargo clippy --all-targets --all-features -- -D warnings
HONK_URL=… HONK_KEY=… cargo test --test integration   # against a real server (use a test project's key)

The version lives in Cargo.toml (VERSION and the User-Agent honk-rust/<version> come from it). Releases: push a tag vX.Y.Z matching it and the release workflow publishes to crates.io with trusted publishing (see CHANGELOG.md).

Links

MIT License.