Skip to main content

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.gorepository.goservice.gohandler.goregister.go, with SQL written by hand through sqlx and no ORM.

Business Flow

Boot sequence (app.New)

  1. config.Load reads every setting from the environment into a typed struct and calls Validate(). An impossible configuration fails the boot immediately (fail-fast), and no package calls os.Getenv on its own mid-code.
  2. 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.
  3. ConnectAll runs within a 30-second budget and rejects duplicate dependency names, since the name is the key used across readiness, metrics, and alerts.
  4. 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.
  5. cmd/api/main.go declares the RabbitMQ topology (exchange, four queues, and a DLQ) on a best-effort basis, then calls server.New(deps, ...Register) to assemble the engine.

Middleware order for every request (server.New)

  1. 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.
  2. middleware.CORSAllowAll() — parity with the source: origin, methods, and headers are all set to * unconditionally.
  3. middleware.Helmet() — the default security header set, except for Cross-Origin-Resource-Policy, because the source sets crossOriginResourcePolicy: false.
  4. httpx.ExceptionMiddleware — converts every error and panic into the {"statusCode","message","error"} envelope, where message may be a string or an array of strings and error is the HTTP reason phrase. Anything that is not an *httpx.Exception becomes a 500 with the message System Failure.
  5. 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 containing transactionId (from the X-Request-Id header, or 12 random hex characters), sessionId (the third segment of Authorization if present), and lang (from x-lang), then echoes X-Request-Id back in the response headers.
  • middleware.AppRateLimit is a Redis-backed limiter replacing the original ThrottlerModule, which was in-memory per pod. It keys on the client IP resolved in order from x-forwarded-for[0]x-real-ipx-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 form Cannot <METHOD> <path>.
  • /livez, /healthz, and /readyz are mounted at the root of the business port rather than under /api, because k8s probes and the ALB health check target that port. /metrics and pprof live only on :9100 and must never be exposed.
  • /livez checks 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 by SHUTDOWN_TIMEOUT.
  • deps.DB, deps.Redis, and deps.AMQP may all be nil, so every Register function 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

FileRole
cmd/api/main.goThe SVC=api binary: calls app.New, declares the AMQP topology, assembles the engine, and handles graceful shutdown
internal/app/app.goapp.New(ctx, cancel, service, role) — shared bootstrap for both api and worker
internal/server/server.goserver.New(deps, registers...) — assembles the gin engine and middleware order; defines Deps and RegisterFunc
internal/server/metrics_middleware.goMetricsMiddleware()
internal/middleware/cors.go, helmet.goCORSAllowAll(), Helmet()
internal/middleware/ratelimit.goAppRateLimit(rdb, ttlMs, limit, blockMs), RouteRateLimit(rdb, limit, windowSecs)
internal/middleware/throttler.goRateLimit(perMinute) — the per-minute limiter used by public content
internal/httpx/exception.goException plus the New/BadRequest/Unauthorized/Forbidden/NotFound/Conflict/InternalServerError constructors
internal/httpx/abort.goAbort(c, err), ExceptionMiddleware(sentry, logger)
internal/httpx/validate.go, checks.goBindAndValidate, Rule, Check (mirroring class-validator)
internal/httpx/jstime.goJSTime — JS Date-style date serialization
internal/clsx/clsx.goMiddleware() and Store (transactionId/sessionId/lang)
internal/deps/*The Dependency contract, Registry, and one subpackage per backend
internal/supervisor/supervisor.goStart(...) and evaluate(downFor, failures, alerted, cfg), written as a pure function
internal/config/config.go, api.go, api_load.goThe 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 named redis serves 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, and booking_event_trigger.
  • Object storage (S3/MinIO) through internal/storagex and internal/s3x.
  • LINE Platform through internal/linehttp; see LIFF Token Verification.
  • Every feature in this section is mounted via a RegisterFunc listed in cmd/api/main.go.
  • On the client-web side the direct counterpart is the app-shell feature, which owns the axios instance, the base URL, and reading error codes out of the envelope.