First commit Go counter service
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"migadu_counters/internal/api"
|
||||
)
|
||||
|
||||
// maxBody bounds every request body (the biggest legitimate body is a few
|
||||
// hundred bytes).
|
||||
const maxBody = 64 << 10
|
||||
|
||||
// httpError is a rejection by the validation layer.
|
||||
type httpError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.msg }
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, api.ErrorResponse{Error: msg})
|
||||
}
|
||||
|
||||
// reject answers an httpError and logs it: a burst of 4xx from mailqueuer
|
||||
// means contract drift or a token mismatch.
|
||||
func (s *Server) reject(w http.ResponseWriter, r *http.Request, route string, e *httpError) {
|
||||
writeError(w, e.status, e.msg)
|
||||
s.d.Logger.Warn("request rejected", "route", route, "remote", r.RemoteAddr, "status", e.status, "reason", e.msg)
|
||||
}
|
||||
|
||||
// statusWriter captures the status code for metrics.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if w.status == 0 {
|
||||
w.status = code
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(p []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
// recoverer keeps the daemon alive when a handler panics. It sits outside
|
||||
// http.TimeoutHandler, which re-panics in the caller's goroutine.
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
s.d.Logger.Error("panic in handler", "panic", fmt.Sprint(p), "path", r.URL.Path, "stack", string(debug.Stack()))
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// observe records request metrics per route and status.
|
||||
func (s *Server) observe(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(sw, r)
|
||||
if sw.status == 0 {
|
||||
sw.status = http.StatusOK
|
||||
}
|
||||
route := r.URL.Path
|
||||
switch route {
|
||||
case "/in", "/out", "/counters":
|
||||
default:
|
||||
route = "other"
|
||||
}
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordRequest(route, strconv.Itoa(sw.status), time.Since(start).Seconds())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// route applies the validation steps common to every endpoint, in order:
|
||||
// method, declared body size, bearer token, source IP, content type (POST).
|
||||
func (s *Server) route(path, method string, h http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != method {
|
||||
w.Header().Set("Allow", method)
|
||||
s.reject(w, r, path, &httpError{http.StatusMethodNotAllowed, "method not allowed"})
|
||||
return
|
||||
}
|
||||
if r.ContentLength > maxBody {
|
||||
s.reject(w, r, path, &httpError{http.StatusRequestEntityTooLarge, "request body too large"})
|
||||
return
|
||||
}
|
||||
if e := s.checkAuth(r); e != nil {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
s.reject(w, r, path, e)
|
||||
return
|
||||
}
|
||||
if e := s.checkSource(r); e != nil {
|
||||
s.reject(w, r, path, e)
|
||||
return
|
||||
}
|
||||
if method == http.MethodPost {
|
||||
if e := checkContentType(r); e != nil {
|
||||
s.reject(w, r, path, e)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) checkAuth(r *http.Request) *httpError {
|
||||
if s.d.AuthToken == "" {
|
||||
return nil // config.Warnings() flags this at startup
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return &httpError{http.StatusUnauthorized, "unauthorized"}
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(h[len(prefix):])), []byte(s.d.AuthToken)) != 1 {
|
||||
return &httpError{http.StatusUnauthorized, "unauthorized"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSource matches RemoteAddr (never X-Forwarded-For: nothing proxies
|
||||
// mailqueuer's calls) against allowed_cidrs when configured.
|
||||
func (s *Server) checkSource(r *http.Request) *httpError {
|
||||
if len(s.d.AllowedCIDRs) == 0 {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return &httpError{http.StatusForbidden, "source address not allowed"}
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
for _, p := range s.d.AllowedCIDRs {
|
||||
if p.Contains(addr) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &httpError{http.StatusForbidden, "source address not allowed"}
|
||||
}
|
||||
|
||||
func checkContentType(r *http.Request) *httpError {
|
||||
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mt != "application/json" {
|
||||
return &httpError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSON reads exactly one JSON object into dst. Unknown fields are
|
||||
// ignored so mailqueuer can add fields without breaking counting.
|
||||
func decodeJSON(r *http.Request, dst any) *httpError {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
return &httpError{http.StatusRequestEntityTooLarge, "request body too large"}
|
||||
}
|
||||
return &httpError{http.StatusBadRequest, "invalid json: " + err.Error()}
|
||||
}
|
||||
if err := dec.Decode(&struct{}{}); err != io.EOF {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
return &httpError{http.StatusRequestEntityTooLarge, "request body too large"}
|
||||
}
|
||||
return &httpError{http.StatusBadRequest, "body must contain exactly one JSON object"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logger returns the configured logger or slog's default.
|
||||
func (d Deps) logger() *slog.Logger {
|
||||
if d.Logger != nil {
|
||||
return d.Logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Package server serves the API (/in, /out, /counters) behind the HTTP
|
||||
// validation layer, and the admin endpoints (/health, /metrics).
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"migadu_counters/internal/api"
|
||||
"migadu_counters/internal/counting"
|
||||
"migadu_counters/internal/domains"
|
||||
"migadu_counters/internal/metrics"
|
||||
"migadu_counters/internal/notify"
|
||||
"migadu_counters/internal/store"
|
||||
"migadu_counters/internal/txlog"
|
||||
)
|
||||
|
||||
// Deps wires the server to the rest of the daemon.
|
||||
type Deps struct {
|
||||
AuthToken string
|
||||
AllowedCIDRs []netip.Prefix
|
||||
HandlerTimeout time.Duration // bounds the synchronous part (validation) and each queued job
|
||||
QueueSize int // counting queue capacity (default 10 000)
|
||||
Workers int // counting workers (default 8)
|
||||
Exempt map[string]bool
|
||||
|
||||
Counter *counting.Counter
|
||||
Store *store.Store
|
||||
Domains func() *domains.DB // current CDB (swapped on reload); nil = not loaded
|
||||
Notifier *notify.Notifier // nil = never notify
|
||||
TxLog *txlog.Logger // nil = transaction log disabled
|
||||
Metrics *metrics.Metrics
|
||||
Gatherer prometheus.Gatherer // served on /metrics; nil = no endpoint
|
||||
Logger *slog.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Server holds the handlers and the counting queue.
|
||||
type Server struct {
|
||||
d Deps
|
||||
queue *counting.Queue
|
||||
|
||||
panicHook func() // tests: injected into the synchronous path
|
||||
}
|
||||
|
||||
// New applies defaults and starts the counting workers.
|
||||
func New(d Deps) *Server {
|
||||
if d.Now == nil {
|
||||
d.Now = time.Now
|
||||
}
|
||||
if d.HandlerTimeout <= 0 {
|
||||
d.HandlerTimeout = 4 * time.Second
|
||||
}
|
||||
d.Logger = d.logger()
|
||||
s := &Server{d: d}
|
||||
obs := counting.Observer{
|
||||
Panic: func(job counting.Job, p any, stack []byte) {
|
||||
s.d.Logger.Error("panic while counting", "panic", fmt.Sprint(p), "route", job.Route, "trace_id", job.TraceID, "stack", string(stack))
|
||||
},
|
||||
}
|
||||
if d.Metrics != nil {
|
||||
obs.Depth = d.Metrics.SetQueueDepth
|
||||
obs.Wait = func(w time.Duration) { d.Metrics.ObserveQueueWait(w.Seconds()) }
|
||||
}
|
||||
s.queue = counting.NewQueue(counting.QueueConfig{Size: d.QueueSize, Workers: d.Workers, Timeout: d.HandlerTimeout}, s.process, obs)
|
||||
return s
|
||||
}
|
||||
|
||||
// Drain stops accepting jobs and waits for the queued ones, bounded by ctx.
|
||||
// Call it after the HTTP server stopped and before the notifier shuts down.
|
||||
func (s *Server) Drain(ctx context.Context) error { return s.queue.Drain(ctx) }
|
||||
|
||||
// Pending is the number of accepted jobs not yet counted.
|
||||
func (s *Server) Pending() int { return s.queue.Pending() }
|
||||
|
||||
// Depth is the number of jobs waiting for a worker.
|
||||
func (s *Server) Depth() int { return s.queue.Depth() }
|
||||
|
||||
// NotifyHooks wires notification outcomes into the transaction log and the
|
||||
// metrics; used by the daemon and by tests.
|
||||
func NotifyHooks(tx *txlog.Logger, m *metrics.Metrics) notify.Hooks {
|
||||
return notify.Hooks{
|
||||
Sent: func(ev notify.Event, url string, attempt int) {
|
||||
if m != nil {
|
||||
m.RecordNotification(ev.Level, "sent")
|
||||
}
|
||||
if tx != nil {
|
||||
tx.NotificationSent(ev, url, attempt)
|
||||
}
|
||||
},
|
||||
Failed: func(ev notify.Event, url string, attempts int, err error) {
|
||||
if m != nil {
|
||||
m.RecordNotification(ev.Level, "failed")
|
||||
}
|
||||
if tx != nil {
|
||||
tx.NotificationFailed(ev, url, attempts, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the API handler: recover → metrics → timeout → routes.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/out", s.route("/out", http.MethodPost, s.handleOut))
|
||||
mux.Handle("/in", s.route("/in", http.MethodPost, s.handleIn))
|
||||
mux.Handle("/counters", s.route("/counters", http.MethodGet, s.handleCounters))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
})
|
||||
timed := http.TimeoutHandler(mux, s.d.HandlerTimeout, `{"error":"request timeout"}`)
|
||||
return s.recoverer(s.observe(timed))
|
||||
}
|
||||
|
||||
// AdminHandler returns /health and /metrics (no auth; bind admin_addr locally).
|
||||
func (s *Server) AdminHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
if s.d.Gatherer != nil {
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(s.d.Gatherer, promhttp.HandlerOpts{}))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleOut(w http.ResponseWriter, r *http.Request) {
|
||||
var req api.OutRequest
|
||||
if e := decodeJSON(r, &req); e != nil {
|
||||
s.reject(w, r, "/out", e)
|
||||
return
|
||||
}
|
||||
v, err := api.ValidateOut(req)
|
||||
if err != nil {
|
||||
s.reject(w, r, "/out", &httpError{http.StatusBadRequest, err.Error()})
|
||||
return
|
||||
}
|
||||
job := counting.Job{
|
||||
Route: counting.RouteOut, TraceID: v.TraceID, Received: s.d.Now(),
|
||||
AuthUser: v.AuthUser.Value, LoginSuffix: v.LoginSuffix,
|
||||
MailFrom: v.MailFrom, MailFromUsable: v.MailFromUsable,
|
||||
Recipient: v.Recipient.Value,
|
||||
}
|
||||
if !v.AuthUser.Usable() {
|
||||
job.SenderUnusable = counting.Unusable{Role: "sender", Value: v.AuthUser.Raw, Reason: v.AuthUser.Reason}
|
||||
}
|
||||
if !v.Recipient.Usable() {
|
||||
job.RecipientUnusable = counting.Unusable{Role: "recipient", Value: v.Recipient.Raw, Reason: v.Recipient.Reason}
|
||||
}
|
||||
s.enqueue(w, r, "/out", job)
|
||||
}
|
||||
|
||||
func (s *Server) handleIn(w http.ResponseWriter, r *http.Request) {
|
||||
var req api.InRequest
|
||||
if e := decodeJSON(r, &req); e != nil {
|
||||
s.reject(w, r, "/in", e)
|
||||
return
|
||||
}
|
||||
v, err := api.ValidateIn(req)
|
||||
if err != nil {
|
||||
s.reject(w, r, "/in", &httpError{http.StatusBadRequest, err.Error()})
|
||||
return
|
||||
}
|
||||
s.enqueue(w, r, "/in", counting.Job{
|
||||
Route: counting.RouteIn, TraceID: v.TraceID, Received: s.d.Now(),
|
||||
MailFrom: v.MailFrom, MailFromUsable: v.MailFromUsable, Recipient: v.Recipient, Junk: v.Junk,
|
||||
})
|
||||
}
|
||||
|
||||
// enqueue hands the validated job to the workers and answers 202; a full
|
||||
// queue answers 503 and the drop is counted and logged.
|
||||
func (s *Server) enqueue(w http.ResponseWriter, r *http.Request, route string, job counting.Job) {
|
||||
if s.panicHook != nil {
|
||||
s.panicHook()
|
||||
}
|
||||
if !s.queue.Enqueue(job) {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordDropped(route)
|
||||
}
|
||||
if s.d.TxLog != nil {
|
||||
s.d.TxLog.Dropped(job.Route, job.TraceID)
|
||||
}
|
||||
s.d.Logger.Error("counting queue full, request dropped", "route", route, "trace_id", job.TraceID, "remote", r.RemoteAddr)
|
||||
writeError(w, http.StatusServiceUnavailable, "queue full")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, api.CountResponse{Accepted: true})
|
||||
}
|
||||
|
||||
// process is the worker body: plan, execute, dispatch notifications for
|
||||
// crossed limits and write the transaction log. ctx is the queue's own
|
||||
// bounded context, never a request's.
|
||||
func (s *Server) process(ctx context.Context, job counting.Job) {
|
||||
plan, err := s.d.Counter.Planner.Plan(job)
|
||||
if err != nil {
|
||||
s.d.Logger.Error("domain lookup failed", "route", job.Route, "trace_id", job.TraceID, "error", err)
|
||||
if s.d.TxLog != nil {
|
||||
s.d.TxLog.Error(job.Route, job.TraceID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, lk := range plan.Lookups {
|
||||
if errors.Is(lk.Err, domains.ErrMalformed) {
|
||||
s.d.Logger.Warn("malformed row in domains db, domain treated as not hosted", "domain", lk.DomainName, "route", job.Route, "trace_id", job.TraceID, "error", lk.Err)
|
||||
}
|
||||
}
|
||||
out, err := s.d.Counter.Execute(ctx, plan)
|
||||
if err != nil {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordRedisError("incr")
|
||||
}
|
||||
if s.d.TxLog != nil {
|
||||
s.d.TxLog.Failed(plan, err)
|
||||
}
|
||||
s.d.Logger.Error("counting failed", "route", plan.Route, "trace_id", plan.TraceID, "error", err)
|
||||
return
|
||||
}
|
||||
if out.Unknown != nil {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordRedisError("incr_unknown")
|
||||
}
|
||||
s.d.Logger.Warn("redis outcome unknown, counts re-read from master", "route", plan.Route, "trace_id", plan.TraceID, "error", out.Unknown)
|
||||
}
|
||||
|
||||
dispatched := make([]bool, len(out.Results))
|
||||
for i, res := range out.Results {
|
||||
if res.Err != nil {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordRedisError("incr")
|
||||
}
|
||||
s.d.Logger.Error("redis error on key", "route", plan.Route, "trace_id", plan.TraceID, "key", res.Key, "error", res.Err)
|
||||
continue
|
||||
}
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordIncrement(res.Level, res.Flow)
|
||||
}
|
||||
if res.Reached {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordLimitReached(res.Level, res.Flow)
|
||||
}
|
||||
ev := notify.Event{TraceID: plan.TraceID, Route: plan.Route, Level: res.Level, ID: res.ID,
|
||||
Flow: res.Flow, Address: res.By, Count: res.Count, Limit: res.Limit}
|
||||
dispatched[i] = s.d.Notifier != nil && s.d.Notifier.Notify(ev)
|
||||
if !dispatched[i] && s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordNotification(res.Level, "disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.d.TxLog != nil {
|
||||
s.d.TxLog.Outcome(out, dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCounters answers GET /counters?address=|domain=[&date=] from the
|
||||
// read side.
|
||||
func (s *Server) handleCounters(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
address, domain := strings.TrimSpace(q.Get("address")), strings.ToLower(strings.TrimSpace(q.Get("domain")))
|
||||
if (address == "") == (domain == "") {
|
||||
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "exactly one of address or domain is required"})
|
||||
return
|
||||
}
|
||||
date, err := api.ParseDate(q.Get("date"), s.d.Now())
|
||||
if err != nil {
|
||||
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "date: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp := api.CountersResponse{Date: date, Levels: []api.CountersLevel{}}
|
||||
var local string
|
||||
if address != "" {
|
||||
addr, err := api.NormalizeEmail(address)
|
||||
if err != nil {
|
||||
s.reject(w, r, "/counters", &httpError{http.StatusBadRequest, "address: invalid e-mail address"})
|
||||
return
|
||||
}
|
||||
addr = domains.Mailbox(addr)
|
||||
resp.Query, resp.Mode, resp.Exempt = addr, "address", s.d.Exempt[addr]
|
||||
at := strings.LastIndex(addr, "@")
|
||||
local, domain = addr[:at], addr[at+1:]
|
||||
} else {
|
||||
resp.Query, resp.Mode = domain, "domain"
|
||||
}
|
||||
|
||||
db := s.d.Domains()
|
||||
if db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "domains db not loaded")
|
||||
return
|
||||
}
|
||||
d, err := db.Lookup(domain)
|
||||
if errors.Is(err, domains.ErrMalformed) {
|
||||
s.d.Logger.Warn("malformed row in domains db, domain treated as not hosted", "domain", domain, "route", "/counters", "error", err)
|
||||
} else if err != nil && !errors.Is(err, domains.ErrNotFound) {
|
||||
writeError(w, http.StatusServiceUnavailable, "domain lookup failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
managed := err == nil
|
||||
if managed {
|
||||
org := d.OrgID
|
||||
resp.Managed, resp.Domain, resp.OrgID = true, d.Name, &org
|
||||
}
|
||||
|
||||
type row struct{ level, id string }
|
||||
var rows []row
|
||||
if resp.Mode == "address" {
|
||||
rows = append(rows, row{domains.AddressLevel, resp.Query})
|
||||
}
|
||||
rows = append(rows, row{domains.DomainLevel, domain})
|
||||
if managed {
|
||||
rows = append(rows, row{domains.OrganizationLevel, strconv.FormatInt(d.OrgID, 10)})
|
||||
}
|
||||
keys := make([]string, 0, 2*len(rows))
|
||||
for _, rw := range rows {
|
||||
keys = append(keys, store.Key(rw.level, rw.id, date, domains.FlowIn), store.Key(rw.level, rw.id, date, domains.FlowOut))
|
||||
}
|
||||
values, err := s.d.Store.Get(r.Context(), keys)
|
||||
if err != nil {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordRedisError("mget")
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, "redis unavailable: "+err.Error())
|
||||
return
|
||||
}
|
||||
limit := func(level, flow string) *int64 {
|
||||
if !managed {
|
||||
return nil
|
||||
}
|
||||
if n, ok := d.Limits.Limit(level, flow, local); ok {
|
||||
return &n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, rw := range rows {
|
||||
resp.Levels = append(resp.Levels, api.CountersLevel{
|
||||
Level: rw.level, ID: rw.id,
|
||||
In: values[2*i], InLimit: limit(rw.level, domains.FlowIn),
|
||||
Out: values[2*i+1], OutLimit: limit(rw.level, domains.FlowOut),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
db := s.d.Domains()
|
||||
if db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "domains db not loaded")
|
||||
return
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "domains db not readable: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := s.d.Store.Ping(ctx); err != nil {
|
||||
if s.d.Metrics != nil {
|
||||
s.d.Metrics.RecordRedisError("ping")
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("redis: %v", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user