GoToolkitBackendTools

Construire des services HTTP Go production-ready avec duck 🦆

Duck est un toolkit Go modulaire qui couvre les problèmes récurrents d'un service HTTP — sans framework, sans magie, sans lock-in.

4 mars 2026
Benoit Maret
10 min de lecture

Chaque projet backend Go finit par résoudre les mêmes problèmes : logging structuré, authentification JWT, arrêt gracieux, chargement de configuration depuis des variables d'environnement, clients HTTP sortants avec retry... À un moment donné, soit on copie-colle depuis son dernier projet, soit on construit quelque chose de réutilisable.

duck logo

Duck

Go version Go report Licence MIT

duck est ce quelque chose de réutilisable. C'est un toolkit Go modulaire que j'ai conçu pour couvrir les problématiques récurrentes d'un service HTTP — sans imposer une architecture, sans vendor lock-in, et sans magie cachée.
Le projet en est toujours à ses débuts, et j'espère augmenter progressivement son scope en me basant sur des besoins réels de multiples projets.

👉 github.com/qwackididuck/duck

Pourquoi duck ?

L'écosystème Go offre d'excellentes bibliothèques individuelles. Mais les assembler de façon cohérente et production-ready — un arrêt gracieux qui draine aussi les goroutines de fond, un middleware de logging qui masque les champs sensibles, un middleware JWT avec rotation de clés — ça prend du temps à chaque projet.

duck n'est pas un framework. C'est une collection de packages indépendants qui résolvent chacun une chose précisément, et qui se composent proprement ensemble.

Installation

go get github.com/qwackididuck/duck

Nécessite Go 1.26+. Les sous-packages sont des imports séparés — on ne tire que les dépendances dont on a besoin :

go get github.com/qwackididuck/duck/server # aucune dépendance externe go get github.com/qwackididuck/duck/jwt # importe go-jose/v4 go get github.com/qwackididuck/duck/oauth2 # importe golang.org/x/oauth2 go get github.com/qwackididuck/duck/metrics # importe prometheus/client_golang

Ce qu'il contient

┌──────────────────────────────────────────────────────────────────────────┐ │ votre service │ ├─────────────┬────────────┬────────────┬────────────┬─────────────────────┤ │ server │ log │ config │ jwt │ oauth2 │ │ graceful │ slog + │ env vars │ go-jose v4 │ Auth Code + PKCE │ │ shutdown │ context │ + files │ + JWKS │ session store │ ├─────────────┴────────────┴────────────┴────────────┴─────────────────────┤ │ middleware : logging · metrics · body limit · compress │ ├──────────────────────────────────────────────────────────────────────────┤ │ httpclient : retry · backoff · logging │

Voici un tour des parties les plus intéressantes.

server — L'arrêt gracieux fait correctement

Le package server gère le cycle de vie complet d'un service HTTP : démarrage, supervision des goroutines de fond, health probes Kubernetes, et arrêt propre.

srv, err := server.New( server.WithAddr(":8080"), server.WithHandler(router), server.WithLogger(logger), server.WithShutdownTimeout(30 * time.Second), ) if err != nil { log.Fatal(err) }

À la réception de SIGINT ou SIGTERM, la séquence d'arrêt est ordonnée et déterministe :

  1. On arrête d'accepter de nouvelles connexions
  2. Le contexte applicatif est annulé — toutes les goroutines reçoivent le signal
  3. Les requêtes HTTP en cours sont drainées
  4. On attend que toutes les goroutines enregistrées aient terminé (jusqu'à ShutdownTimeout)
  5. Le processus se termine proprement

Les goroutines de fond sont enregistrées via srv.Go() et reçoivent le contexte annulable :

srv.Go(func(ctx context.Context) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: runPeriodicJob(ctx) }

Health probes Kubernetes

srv, err := server.New( server.WithAddr(":8080"), server.WithHandler(router), server.WithHealthChecks("payments-service", server.WithKOStatus(http.StatusServiceUnavailable), ), server.WithDependency(&PostgresChecker{db: db}), server.WithDependency(&RedisChecker{client: rdb}), )

GET /health répond à la liveness probe (toujours OK tant que le process tourne). GET /ready vérifie chaque dépendance enregistrée et retourne 503 si l'une d'elles est en échec — retirant automatiquement le pod du pool du load balancer.

log — slog avec propagation par contexte

Le package log est une fine couche au-dessus du standard log/slog. Il retourne un simple *slog.Logger — aucun type custom à transporter dans le codebase.

La fonctionnalité clé est la propagation d'attributs par contexte : on attache des champs une seule fois, et ils apparaissent automatiquement dans tous les appels de log qui utilisent ce contexte.

ctx := ducklog.ContextWithAttrs(r.Context(), slog.String("request_id", requestID), slog.String("user_id", session.UserID), ) // Dans une couche service — pas besoin de passer request_id manuellement : ducklog.FromContext(ctx, logger).Info("commande créée", slog.String("order_id", "ord_123"), )

Sortie :

{ "level": "INFO", "msg": "commande créée", "request_id": "req-3f8a", "user_id": "usr_42", "order_id": "ord_123" }

Ce pattern peut sembler anodin, mais il élimine une quantité significative de boilerplate dès que le service grossit au-delà de quelques handlers.

config — Configuration type-safe avec les génériques

Le package config charge la configuration depuis des variables d'environnement et/ou des fichiers JSON/YAML grâce aux génériques Go. Le comportement de chaque champ est déclaré dans les struct tags.

type Config struct { Addr string `duck:"env=ADDR,default=:8080"` DatabaseURL string `duck:"env=DATABASE_URL,required"` ShutdownTimeout time.Duration `duck:"env=SHUTDOWN_TIMEOUT,default=30s"` AllowedOrigins []string `duck:"env=ALLOWED_ORIGINS,sep=,"` } cfg, err := config.Load[Config]( config.WithEnv(), config.WithFile("config.yaml"),

Ordre de résolution : variables d'env → fichier → valeurs par défaut du tag. Si un champ required est absent de toutes les sources, Load retourne une erreur wrappant config.ErrMissingMandatory. Cela empêche les mauvaises configurations silencieuses d'atteindre la production.

jwt — Construit sur go-jose v4

La génération et la validation JWT s'appuient sur go-jose v4. Les claims sont génériques — on définit son propre struct en embarquant josejwt.Claims.

type AppClaims struct { josejwt.Claims TenantID string `json:"tenantId"` Role string `json:"role"` } provider, err := jwt.WithHMACKey(jwt.HS256, []byte("votre-secret-32-bytes-minimum!!")) token, err := jwt.Generate(AppClaims{ Claims: josejwt.Claims{

Le middleware est tout aussi concis :

router.Use(jwt.Middleware[AppClaims](provider)) // Dans un handler : claims, ok := jwt.ClaimsFromContext[AppClaims](r.Context())

Rotation de clés sans interruption de service

newProvider, _ := jwt.WithHMACKey(jwt.HS256, newSecret) oldProvider, _ := jwt.WithHMACKey(jwt.HS256, oldSecret) rotatingProvider := jwt.NewMultiKeyProvider(newProvider, oldProvider) // Signe avec la nouvelle clé, vérifie avec les deux.

Providers externes via JWKS

Fonctionne nativement avec Keycloak, Auth0, AWS Cognito, Azure AD :

provider, err := jwt.NewJWKSProvider( "https://keycloak.company.com/realms/myrealm/protocol/openid-connect/certs", jwt.WithJWKSRefreshInterval(30 * time.Minute), jwt.WithJWKSAlgorithms(jwt.RS256), )

Les clés publiques sont récupérées, mises en cache et rafraîchies automatiquement. En cas d'échec du rafraîchissement, les dernières clés connues sont conservées (comportement stale-on-error).

oauth2 — Authorization Code + PKCE

Le package oauth2 gère le cycle OAuth2/OIDC complet — PKCE, vérification CSRF, échange de token, création de session et logout — construit sur golang.org/x/oauth2.

auth, err := oauth2.New( oauth2.WithProvider(providers.Google(clientID, clientSecret)), oauth2.WithProvider(providers.GitHub(clientID, clientSecret)), oauth2.WithRedirectURL("https://myapp.com/auth/{provider}/callback"), oauth2.WithSessionStore(store.NewMemoryStore()), oauth2.WithOnLogin(func(ctx context.Context, id oauth2.Identity) (oauth2.SessionData, error) { user, _ := db.Upsert(id.Provider, id.ProviderID, id.Email, id.Name) return oauth2.SessionData{UserID: user.ID}, nil }), oauth2.WithSuccessRedirect("/dashboard"),

Quatre routes sont montées automatiquement : login, callback, logout, et logout-all. Le hook OnLogin est la seule logique métier à implémenter — tout le reste (code verifier PKCE, cookie de state, échange de token) est géré en interne.

En production, on remplace le store mémoire par Redis :

oauth2.WithSessionStore(store.NewRedisStore(rdb, store.WithKeyPrefix("myapp:sessions:"), store.WithTTL(7 * 24 * time.Hour), ))

middleware — Une chaîne composable

chi est utilisé comme routeur. Les middlewares se composent avec middleware.Chain :

router.Use(middleware.Chain( middleware.Logging(logger, middleware.WithObfuscatedHeaders("Authorization", "Cookie"), middleware.WithObfuscatedBodyFields("password", "token"), ), middleware.BodyLimit(1 * 1024 * 1024), middleware.Compress(), middleware.HTTPMetrics(promProvider, middleware.WithPathCleaner(func(r *http.Request) string { return chi.RouteContext(r.Context()).RoutePattern()

Quelques détails notables :

  • Logging middleware génère ou propage X-Request-Id, et supporte la capture du body avec limitation de taille et obfuscation des champs — utile pour déboguer sans fuiter des credentials dans les logs.
  • BodyLimit utilise une double protection : vérification de Content-Length en premier, puis http.MaxBytesReader pour les clients qui mentent ou omettent ce header.
  • Compress active le gzip paresseusement — seulement quand le body dépasse un seuil de taille et que le Content-Type est compressible. Les instances gzip.Writer sont poolées via sync.Pool.
  • HTTPMetrics utilise chi.RouteContext pour normaliser les chemins comme /users/123 en /users/{id}, évitant une cardinalité élevée dans les labels Prometheus.

httpclient — Retry, backoff et corrélation de requêtes

client := httpclient.New( httpclient.WithTimeout(30 * time.Second), httpclient.WithRetry( []httpclient.RetryCondition{ httpclient.RetryOnStatusCodes(429, 502, 503, 504), httpclient.RetryOnNetworkErrors(), }, httpclient.WithMaxAttempts(3), httpclient.WithExponentialBackoff(100 * time.Millisecond), ),

Le backoff exponentiel utilise le full jitter (random(0, base × 2ⁿ)) pour éviter le thundering herd problem lorsque plusieurs services redémarrent simultanément.

Le transport de logging se situe en couche externe, de sorte que chaque tentative — y compris les retries — est loggée individuellement avec son propre code de statut et sa durée. Il propage aussi automatiquement X-Request-Id vers les services en aval, permettant une corrélation de requêtes end-to-end sans câblage supplémentaire.

Principes de conception

duck a été construit autour de quelques principes explicites qui ont orienté chaque décision d'API.

Functional options partout. Chaque fonction With* est rétrocompatible. Ajouter une nouvelle option ne casse jamais un appel existant — une propriété importante pour un toolkit partagé.

Génériques pour la type safety. statemachine.Run[D], jwt.Middleware[C], config.Load[T] — les erreurs de type sont détectées à la compilation, sans interface{} ni cast à l'exécution.

Extensibilité par interfaces. server.Stater, middleware.HTTPMetricsProvider, oauth2.Provider, oauth2.SessionStore sont toutes des interfaces. On peut substituer n'importe quel backend (Postgres, MongoDB, un provider d'identité custom) sans toucher aux internals de duck.

Types standards, zéro lock-in. httpclient.New retourne *http.Client. log.New retourne *slog.Logger. On ne transporte jamais un wrapper spécifique à duck dans le codebase. duck reste aux bords.

Pas de magie. Aucune fonction init(), aucune génération de code, aucun état global. Ce qu'on configure est ce qui s'exécute.

Outillage

duck utilise golangci-lint pour l'analyse statique (configuré dans .golangci.yml) et CodeRabbit pour les revues de code assistées par IA sur chaque pull request.

Conclusion

duck ne dicte pas comment structurer son service. Il n'oblige pas à utiliser tous ses packages. C'est une collection d'utilitaires bien délimités et bien typés qui couvrent les problèmes que je résolvais continuellement depuis zéro — et que je n'ai plus à résoudre.

Le code source, les exemples et la documentation complète sont sur GitHub :

👉 github.com/qwackididuck/duck

Des exemples exécutables pour chaque package se trouvent dans examples/. Chacun peut être lancé avec go run ./examples/<n>.

Articles