Adopting aioz-common in a service
Each package documents itself. This is the part no single package README can say: what a service wires up on day one, in what order, and which of the choices are actually load-bearing.
go get gitlab.internal/tuan.quang.tran/aioz-commonThe shape of a service
// At init, so the flags exist before cobra parses them.
config.Bind(cmd, &cfg, config.ConfDir(dir))
func run(ctx context.Context, cmd *cobra.Command) error {
// 1. Configuration, before anything that needs it.
if _, err := config.Load(cmd); err != nil {
return err
}
if err := config.CheckSecrets(&cfg); err != nil {
return err // refuse to start rather than run on an empty key
}
// 2. Logging, so everything after this is observable.
handler, _, err := logging.New(cfg.Logging)
if err != nil {
return err
}
log := slog.New(handler)
// 3. Metrics, including the build stamp.
monitor.Register(nil)
version.Register(nil)
// 4. Components, started together and closed in reverse.
group := lifecycle.NewGroup(log)
group.Add(lifecycle.Item{
Name: "debug",
Run: debugServer.Run,
Close: debugServer.Close,
})
// A connection pool: Close alone, no Run. Open has already dialed and
// pinged, so a bad address failed above rather than on the first request.
cache, err := redis.Open(ctx, log, cfg.Redis)
if err != nil {
return err
}
monkit.Default.ScopeNamed("redis").Chain(cache)
group.Add(lifecycle.Item{Name: "redis", Close: cache.Close})
// The same shape, and the same reason: pgxpool.New returns a pool that has
// not connected to anything, so without this ping a wrong password starts
// cleanly and fails on the first query.
db, err := postgres.Open(ctx, log, cfg.Postgres)
if err != nil {
return err
}
monkit.Default.ScopeNamed("postgres").Chain(db)
group.Add(lifecycle.Item{Name: "postgres", Close: db.Close})
// A supervised connection: Run reconnects for as long as it is running.
// Register it before anything built on it, because the group closes in
// reverse and the connection has to close last.
broker := rabbitmq.New(log, cfg.RabbitMQ)
monkit.Default.ScopeNamed("rabbitmq").Chain(broker)
group.Add(lifecycle.Item{Name: "rabbitmq", Run: broker.Run, Close: broker.Close})
// One outbound client per dependency: the name is what its metrics are
// tagged by, and each dependency gets its own pool and its own timeout.
// There is no Chain call here - an http.Transport exports no pool gauge to
// poll, so every number this one has is pushed as it happens.
billing, err := httpclient.New(log, cfg.Billing)
if err != nil {
return err
}
group.Add(lifecycle.Item{Name: "billing", Close: func() error {
billing.CloseIdleConnections()
return nil
}})
runners, ctx := errgroup.WithContext(ctx)
group.Run(ctx, runners)
return runners.Wait()
}Group.Run takes an errgroup.Group and starts each item in it, so the caller owns the wait and the first real failure cancels the rest. Item.Run and Item.Close are both optional: a component that only needs stopping, such as a cache or a connection pool, supplies Close alone - redis.Client and postgres.DB above are exactly that shape, and rabbitmq.Conn is the other one, a component whose Run supervises something for the life of the process.
Steps 1 and 2 are ordered for a reason: a configuration error has to be reportable, and logging.Bootstrap exists precisely so there is a logger before the configured one is built.
Retries
retry is the backoff every service was writing by hand, and none of those loops waited a random amount of time. When a dependency restarts, every instance that was talking to it fails in the same instant and reconnects in the same instant; full jitter is what breaks that up. Pass a classifier so a bug is not retried four times:
backoff := retry.New(cfg.Publish.Retry, retry.WithRetryable(isTransient))
err := backoff.Do(ctx, func(ctx context.Context) error { return publishOnce(ctx) })An HTTP client never retries on its own, because a client that retries by default triples the load on a dependency that is already failing, and a POST that timed out may well have succeeded. A call site that should retry says so with httpclient.Retry, which builds a fresh request per attempt:
policy := httpclient.Retry{Name: "billing", MaxAttempts: 4}
resp, err := policy.Do(ctx, billing, func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
})Retryable decides whether an attempt is worth another and Wait how long to sleep before the next; both have sensible defaults, and both are plain functions you can replace. Every attempt shares the context's deadline, so a retrying call site has to set one that covers all the attempts, not one. See httpclient.
Configuration
Defaults, help text and persistence rules live on the struct. See config for the full tag reference.
The one rule worth repeating here, because getting it wrong is a security bug rather than a bug:
type Config struct {
Wallet struct {
Password string `help:"wallet encryption password" secret:"true"`
}
}A secret field may not carry a default or releaseDefault; Bind panics if it does. A default reaches production, so a placeholder password in the source tree is the password of every deployment that never overrode it, and it is in every clone, image and CI log. It is not a weak secret, it is a public one.
Then call CheckSecrets at startup, or the rule only covers half the problem: without a shipped placeholder and without a startup check, removing the placeholder silently yields an empty key.
Two flags in httpclient are hidden:"true" - tls-skip-verify and allow-downgrade - so neither appears in help or in a generated config file. Both disable a protection, and a setting that disables a protection should have to be typed deliberately rather than found in a file.
For a local convenience value, pair a devDefault with an explicitly empty releaseDefault:
Password string `secret:"true" devDefault:"dev-only" releaseDefault:""`Build stamp and metrics
A plain go build already gives you the commit, the build time and whether the tree was dirty. Mount the debug server and /version/ works with no wiring.
Two lines get the rest:
monitor.Register(nil) // process and runtime series, so /metrics is not empty
version.Register(nil) // the version_info seriesOnly the semantic version needs a Makefile flag, because go build does not read your git tag:
VERSION_PKG := gitlab.internal/tuan.quang.tran/aioz-common/version
LDFLAGS := -X $(VERSION_PKG).buildVersion=$(shell git describe --tags --abbrev=0) \
-X $(VERSION_PKG).channel=release--abbrev=0 matters: bare git describe yields v2.4.1-3-gabc1234, which semver reads as a prerelease of v2.4.1, so a build three commits past the tag sorts below it. -X also only patches a plain string variable - aimed at a struct field it names a symbol that does not exist and the linker silently drops it.
channel selects which config defaults apply (devDefault vs releaseDefault). It is declared rather than derived, because it is a choice about how the binary should behave. Info.Release is the opposite: it is derived from evidence, so a build from a dirty tree reports release: false however it was labelled.
Choosing a credential
This is the decision people get wrong most often, so it gets a table.
| You need | Use | Why |
|---|---|---|
| A session, CSRF token, invite, password reset | secret.NewToken | Opaque, hashed at rest, revoked with a DELETE |
| An API key a machine presents | secret.NewKey | Public id for O(1) lookup, secret half hashed, checksum for offline rejection |
| MFA recovery codes | secret.NewCodes | Crockford base32, folds what a person mistypes |
| A token a verifier checks without reaching your database | jwt | Self-describing, signed |
| A database key or a correlation id | uuid | Sortable, not a credential |
| A password | password | argon2id at OWASP's floor, one derivation even for an unknown account, legacy bcrypt upgraded on login; none of the above is slow enough |
Default to secret, not jwt. A JWT cannot be revoked before it expires, which is the price of the verifier not needing a lookup. If both ends already talk to the same coordinator on every request, you are paying that price for nothing. Reach for jwt when the verifier is a third party, an edge worker, or another company's service.
uuid.New() is UUIDv7: about 74 random bits behind a 48-bit millisecond timestamp. It is a database key and never a bearer token.
Security controls
The controls from the OWASP cheat sheets that every service needs, none of which imports a web framework. A gin or echo service calls them from a few lines of middleware; each README has both.
| Control | Use | Wire it |
|---|---|---|
| The client's address behind a proxy | clientip | trusted-proxies set to the load balancers, and gin's SetTrustedProxies / echo's IPExtractor from the same list |
| Brute force on login, OTP, forgot-password | ratelimit | before the user lookup; account, account+IP and IP keys; Reset the account keys on success |
| Password storage | password | Check on signup, Verify on login with "" for an unknown account, rehash on NeedsRehash |
| Security headers | httpsec | Headers on every response |
| CORS | httpsec.CORS | the real allowlist; empty means off, never * with credentials. A service with a dashboard and SDK users needs one policy per route group: "One API, two audiences" in the httpsec README |
| A sort from the query string | postgres.SortColumns | the documented keys only; ErrInvalidSort is a 400 |
| File uploads | upload | LimitRequest before FormFile, Policy.Open, store under a random id |
| Secrets in logs | logging | on by default; log.redact for service-specific keys |
| A container health check | debug.HealthCommand | pin debug.addr, add the subcommand, and the Dockerfile's HEALTHCHECK runs it |
internal/e2e/owasp_test.go wires all of them into one net/http server and tests them from the outside; it is the shortest complete example.
Testing against these packages
Three of the packages exist mostly to make tests deterministic, and a service that ignores them ends up with a slow, flaky suite:
time2- take acontext.Contextand read the clock withtime2.Now(ctx). A test then advances a machine instead of sleeping.jwtdoes this, which is how its expiry tests run in microseconds.cycle-TriggerWait()runs a background chore once, synchronously, so a test asserts on the result rather than on a timer.stats/testcontext- a context canceled when the test ends, withGo/Wait/Cleanupfor background goroutines.
ctx, machine := time2.WithNewMachine(context.Background())
machine.BlockThenAdvance(ctx, 1, time.Hour) // wait for the timer, then jumpUse BlockThenAdvance, not bare Advance: if the code under test has not created its timer yet, the jump passes it by and the test hangs or passes for the wrong reason.
The HTTP API stack
httpapi is team-playbook's HTTP API standard as code. An echo service calls echoapi.Setup, a gin service ginapi.Setup, with the same validated httpapi.Stack; both get the request id, security headers and CORS, access log, recovery, body limit, metrics and the standard error body, in the same order. Handlers answer through OK/Created/List, read pages with Page, bind with Bind, and declare each GET's cache policy with Cache. Replace a service's own envelope, error handler and hand-written Cache-Control strings with these, one route group at a time.
Errors that cross the HTTP boundary
apierr carries a status, a machine-readable reason slug and the underlying cause. A service defines its own reasons on top rather than inventing a parallel error type.
Migrating from the old packages
| Old import | New import |
|---|---|
aioz-config | aioz-common/config |
aioz-logger | aioz-common/logger |
aioz-stats/... | aioz-common/stats/... |
aioz-common/stats/version | aioz-common/version |
aioz-template/internal/utils/... | aioz-common/{apierr,cycle,lifecycle,logging} |
stats/version still works: it is aliases with deprecation notices. Point new -ldflags at the new path, though, because the link-time variables live there.
For a service still on depin's copies of these utilities, the twelve packages imported from depin kept their original package names, so the change is an import path rewrite rather than an API rewrite:
aioz-depin/internal/memory -> aioz-common/memory
aioz-depin/pkg/common/time2 -> aioz-common/time2
aioz-depin/pkg/readcloser -> aioz-common/readcloserThe exceptions are the packages that already existed here under a different API - lifecycle, debug, version, testcontext, cfgstruct and grpcerr - which need a real reconciliation rather than a rewrite.