Core HTTP Server & Middleware
Overview
This feature is the backbone of the entire client-api service. It is not a business endpoint, but the layer every endpoint passes through. It covers bootstrap (loading config, connecting dependencies, opening the admin port, and watching for outages), assembling the gin engine, the order of global middleware, the NestJS-compatible error envelope, and the application-level rate limiter.
One thing to understand before reading any other feature: this service is a 1:1 port of the original NestJS API — error shapes, validation messages, date serialization (JSTime), and every route path are preserved so it can replace the old Node service without any client-side change. Every domain follows the same pattern: entity.go → repository.go → service.go → handler.go → register.go, with SQL written by hand through sqlx and no ORM.
Business Flow
Boot sequence (app.New)
config.Loadreads every setting from the environment into a typed struct and callsValidate(). An impossible configuration fails the boot immediately (fail-fast), and no package callsos.Getenvon its own mid-code.- Only dependencies that are actually configured get created (auto-enabled by their host variable): the sqlx pool (
DB_HOST), the named Redis client (REDIS_*), and the AMQP publisher (AMQP_URLS). Each implements a four-method contract —Name/Connect/Ping/Close— and registers itself in the registry. ConnectAllruns within a 30-second budget and rejects duplicate dependency names, since the name is the key used across readiness, metrics, and alerts.- The health checker (ticker ping), the admin server on
:9100(metrics/version/pprof), and the supervisor all start. The supervisor watches each dependency independently: on failure it retries on a schedule, notifies the chat webhook after one minute, and after three minutes drains and exits so the orchestrator restarts the pod. cmd/api/main.godeclares the RabbitMQ topology (exchange, four queues, and a DLQ) on a best-effort basis, then callsserver.New(deps, ...Register)to assemble the engine.
Middleware order for every request (server.New)
MetricsMiddleware()— the outermost layer, recording status and latency for every request. Labels use the route pattern rather than the raw path to keep cardinality bounded.middleware.CORSAllowAll()— parity with the source: origin, methods, and headers are all set to*unconditionally.middleware.Helmet()— the default security header set, except forCross-Origin-Resource-Policy, because the source setscrossOriginResourcePolicy: false.httpx.ExceptionMiddleware— converts every error and panic into the{"statusCode","message","error"}envelope, wheremessagemay be a string or an array of strings anderroris the HTTP reason phrase. Anything that is not an*httpx.Exceptionbecomes a 500 with the messageSystem Failure.- In non-production environments only,
gin.Logger()is added as the frontmost layer.
Two additional layers on the /api group
clsx.Middleware()builds a request-scoped store containingtransactionId(from theX-Request-Idheader, or 12 random hex characters),sessionId(the third segment of Authorization if present), andlang(fromx-lang), then echoesX-Request-Idback in the response headers.middleware.AppRateLimitis a Redis-backed limiter replacing the original ThrottlerModule, which was in-memory per pod. It keys on the client IP resolved in order fromx-forwarded-for[0]→x-real-ip→x-client-ip→ socket, with a sliding window (RATE_LIMIT_APP_TTL), a limit (RATE_LIMIT_APP_LIMIT), and a block duration (RATE_LIMIT_BLOCK_DURATION). Exceeding the limit sets a block key so subsequent requests return 429 immediately without being counted. Every Redis error fails open — traffic is never rejected just because storage is broken.
Business rules worth remembering
- Unknown routes are handled by
NoRoute, which returns a 404 envelope with a message of the formCannot <METHOD> <path>. /livez,/healthz, and/readyzare mounted at the root of the business port rather than under/api, because k8s probes and the ALB health check target that port./metricsand pprof live only on:9100and must never be exposed./livezchecks the process only — dependencies must never fail liveness, since restarting a pod does not help when the database is down./readyz, by contrast, is tied to every dependency's ping plus the draining flag.- Shutdown order: cancel the context → stop accepting requests → call
SetDraining()so readiness fails first and the load balancer withdraws the pod → close the admin server → close dependencies in reverse order. The whole sequence is bounded bySHUTDOWN_TIMEOUT. deps.DB,deps.Redis, anddeps.AMQPmay all be nil, so everyRegisterfunction must be nil-safe to let the service boot without any backend. In that state the rate limiter and cache degrade to no-ops.
Key Files & Functions
| File | Role |
|---|---|
cmd/api/main.go | The SVC=api binary: calls app.New, declares the AMQP topology, assembles the engine, and handles graceful shutdown |
internal/app/app.go | app.New(ctx, cancel, service, role) — shared bootstrap for both api and worker |
internal/server/server.go | server.New(deps, registers...) — assembles the gin engine and middleware order; defines Deps and RegisterFunc |
internal/server/metrics_middleware.go | MetricsMiddleware() |
internal/middleware/cors.go, helmet.go | CORSAllowAll(), Helmet() |
internal/middleware/ratelimit.go | AppRateLimit(rdb, ttlMs, limit, blockMs), RouteRateLimit(rdb, limit, windowSecs) |
internal/middleware/throttler.go | RateLimit(perMinute) — the per-minute limiter used by public content |
internal/httpx/exception.go | Exception plus the New/BadRequest/Unauthorized/Forbidden/NotFound/Conflict/InternalServerError constructors |
internal/httpx/abort.go | Abort(c, err), ExceptionMiddleware(sentry, logger) |
internal/httpx/validate.go, checks.go | BindAndValidate, Rule, Check (mirroring class-validator) |
internal/httpx/jstime.go | JSTime — JS Date-style date serialization |
internal/clsx/clsx.go | Middleware() and Store (transactionId/sessionId/lang) |
internal/deps/* | The Dependency contract, Registry, and one subpackage per backend |
internal/supervisor/supervisor.go | Start(...) and evaluate(downFor, failures, alerted, cfg), written as a pure function |
internal/config/config.go, api.go, api_load.go | The complete typed Config |
Routes assembled at this layer: GET /livez, GET /healthz, and GET /readyz at the root. For /api and /api/health, see Health Checks & Probes.
Connections to Other Services
- PostgreSQL through the sqlx pool (
internal/platform/db); every repository shares the same pool. - Redis through the named manager (
internal/platform/redisx); the client namedredisserves rate limiting, caching, and OTP sessions. - RabbitMQ through
internal/amqp(HA publisher, confirms,EnsureQueue) across four queues:line_change_richmenu,friend_track_event_trigger,booking_notification, andbooking_event_trigger. - Object storage (S3/MinIO) through
internal/storagexandinternal/s3x. - LINE Platform through
internal/linehttp; see LIFF Token Verification. - Every feature in this section is mounted via a
RegisterFunclisted incmd/api/main.go. - On the client-web side the direct counterpart is the
app-shellfeature, which owns the axios instance, the base URL, and reading error codes out of the envelope.