Skip to content
honk-mePublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

honk-me (Rust)

CI crates.io docs.rs

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

  • 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-me
cargo add tokio --features macros,rt-multi-thread   # if you don't have a runtime yet

or, for synchronous code, cargo add honk-me --features blocking.

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

Quick start

use honk_me::Honk;

#[tokio::main]
async fn main() -> honk_me::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-me.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_me::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_me::{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.send(&msg, format!("request-{}", req.id).as_str()).await?;

Problems and recoveries

A problem opens an incident for its group key; a recovery closes it.

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

Sending a message

send(&message, idempotency_key) returns Accepted { id, duplicate, received_at } once 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 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", "Emily Carter asked for a quote: 3 rooms, 2 bathrooms")
    .group_key("requests/4812")
    .action("Reply", "mailto:emily@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-me.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_me::Error;

match honk.send(&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-me = { version = "0.2", features = ["blocking"] }
use honk_me::blocking::Honk;

let honk = Honk::from_env()?;
honk.beep("Backup finished", "nightly pg_dump took 42 s").send()?;
honk.send(&honk_me::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-me-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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages