Go

Le paquet honk est un client compatible avec context, construit uniquement sur la bibliothèque standard et sûr en accès concurrent. Le même module fournit la CLI honk-me pour les scripts, cron et la CI.

Code source
github.com/honk-me/honk-go · Licence MIT
Prérequis
Go 1.22 ou plus récent.

Installation

La CLI est aussi jointe à chaque release GitHub sous forme de binaires précompilés.

Terminal
go get github.com/honk-me/honk-go                          # library (Go 1.22+)
go install github.com/honk-me/honk-go/cmd/honk-me@latest   # CLI

Une clé d’ingestion (honk_…) permet à quiconque la détient d’envoyer des messages dans son projet. Gardez-la sur vos serveurs, dans vos tâches et dans les secrets de votre CI, jamais dans un navigateur ni dans une app mobile ou de bureau.

Envoyer un événement

Ou explicitement : honk.New(honk.Options{URL: "https://honk-me.app", Key: key}). Une erreur nil signifie que Honk a enregistré le message, pas qu’un push a été distribué.

import honk "github.com/honk-me/honk-go" // package honk

c, err := honk.FromEnv() // HONK_URL, HONK_KEY (+ optional HONK_SOURCE, HONK_ENVIRONMENT, HONK_CHANNEL)
if err != nil {
	log.Fatal(err)
}
_, err = c.Beep(ctx, "Backup finished", "nightly pg_dump took 42 s")

Recette : une demande client sur votre téléphone

Enregistrez d’abord la demande, puis notifiez en dehors du traitement de la requête. Un groupe et une clé d’idempotence par demande.

var notifier, _ = honk.FromEnv() // create once, reuse (keep-alive)

func onCustomerRequest(r CustomerRequest) {
	// ...save the request first, then notify off the request path:
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		_, err := notifier.Send(ctx, honk.Message{
			Title:    truncate("New request: "+r.Subject, 150),
			Message:  truncate(fmt.Sprintf("%s (%s) asked: %s", r.Name, r.Company, r.Body), 2000),
			Priority: honk.PriorityHigh, // push right away
			Category: honk.CategoryCustomers,
			Channel:  "requests",
			GroupKey: fmt.Sprintf("requests/%d", r.ID), // one group per request
			URL:      fmt.Sprintf("https://shop.example.com/admin/requests/%d", r.ID),
			Metadata: map[string]any{"request_id": strconv.Itoa(r.ID)},
		}, honk.WithIdempotencyKey(fmt.Sprintf("request-%d", r.ID)))
		if err != nil {
			log.Printf("honk: %v", err) // never fail the customer's request
		}
	}()
}

func truncate(s string, n int) string { // by runes, never splits UTF-8
	if r := []rune(s); len(r) > n {
		return string(r[:n-1]) + "…"
	}
	return s
}

L’échelle Honk et les incidents

Les helpers acceptent des options comme WithGroupKey, WithChannel et WithIdempotencyKey.

c.Loud(ctx, "Disk 91%", "/var on app-01", honk.WithGroupKey("disk/app-01/var"))
c.Light(ctx, "Deploy started", "v4.2.0", honk.WithChannel("deploys"))
c.Problem(ctx, "db/backup", "Backup failed", "pg_dump exited with 1")      // a long honk by default
c.Recovery(ctx, "db/backup", "Backup OK", "pg_dump finished in 41 s")      // a beep by default

Erreurs

Les erreurs sont des *honk.Error et se comparent aux sentinelles avec errors.Is. Retryable() indique s’il faut réessayer avec la même clé.

_, err := c.Long(ctx, "Payment failed", "Stripe declined order 1042", honk.WithGroupKey("payments/stripe"))
var he *honk.Error
switch {
case err == nil:
case errors.Is(err, honk.ErrValidation):
	log.Printf("bug: %v", err) // he.Fields says what to fix
case errors.As(err, &he) && he.Retryable():
	requeue(he.IdempotencyKey, he.RetryAfter)
default:
	log.Print(err)
}

Des nouvelles tentatives sans doublon

  • Chaque envoi porte une Idempotency-Key : la vôtre, ou un UUIDv7 généré pour l’occasion. La même clé est réutilisée à chaque nouvelle tentative, et pendant 24 heures Honk répond à une requête rejouée avec l’identifiant d’origine et duplicate: true : une réponse perdue ne crée jamais de second message.
  • Une nouvelle tentative n’a lieu qu’en cas d’erreur réseau, de délai dépassé, de 429 ou de 5xx, avec un backoff exponentiel et un jitter complet, jamais avant le Retry-After du serveur. Les autres réponses 4xx ne donnent lieu à aucune nouvelle tentative : corrigez plutôt la requête.
  • Chaque tentative expire au bout de 5 secondes, et tout s’arrête au bout de 30 secondes au total. Une attente qui dépasserait cette échéance, comme un quota quotidien remis à zéro à minuit, échoue immédiatement et indique quand réessayer.
  • Les champs sont vérifiés localement avant l’envoi, et tous les champs invalides sont signalés en une seule fois.