Notifications push depuis 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 pour les scripts, cron et la CI.

Paquet
github.com/honk-hq/go Publié
Code source
github.com/honk-hq/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-hq/go                          # library (Go 1.22+)
go install github.com/honk-hq/go/cmd/honk@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-honk.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-hq/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.Honk(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.Uhoh(ctx, "db/backup", "Backup failed", "pg_dump exited with 1")                             // a long honk by default
c.Phew(ctx, "db/backup", "Backup is back", "pg_dump finished in 41 s. Nobody needs to know.")  // 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.