health.go
⎇
Raw
1package handler
2
3import (
4 "context"
5 "encoding/json"
6 "maps"
7 "net/http"
8 "slices"
9 "strings"
10 "sync"
11 "time"
12
13 "vidarchive/internal/util"
14)
15
16// healthProbeTimeout caps the external version lookups so the endpoint always
17// answers promptly, even when a tool is wedged.
18const healthProbeTimeout = 3 * time.Second
19
20// toolCacheTTL bounds how stale the reported versions may be. /healthz is
21// public and each lookup forks a process, so without a cache anyone who can
22// reach the port costs the host three processes per request.
23const toolCacheTTL = time.Minute
24
25// toolRetryTTL is the shorter TTL used when a probe timed out. A timeout says
26// less about the tool than a real answer, so it is worth re-probing sooner. It
27// is still cached, though: not caching it hands an unauthenticated caller three
28// forks per request for as long as the tool stays wedged.
29const toolRetryTTL = 10 * time.Second
30
31// toolCache holds the last tool version probe. The mutex is held across the
32// probe itself, so a burst of requests collapses into one set of forks. ttl
33// varies per result; see toolRetryTTL.
34type toolCache struct {
35 mu sync.Mutex
36 at time.Time
37 ttl time.Duration
38 results map[string]string
39}
40
41type healthResponse struct {
42 Status string `json:"status"`
43 Dependencies map[string]string `json:"dependencies"`
44}
45
46// Health reports whether the app can reach its database and external tools. It
47// returns 503 when the database is unreachable, so a container healthcheck or
48// orchestrator can act on it. A missing yt-dlp/ffmpeg is reported but does not
49// fail the check: the UI still works, only downloads are affected.
50func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
51 // Bound the whole probe: a health check that can hang is worse than useless,
52 // since an orchestrator reads a stuck request as "still starting".
53 ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
54 defer cancel()
55
56 resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)}
57
58 // The database is probed first because it alone decides the status code, and
59 // it must get the budget before a wedged tool can spend it — otherwise a slow
60 // yt-dlp reports the database as unreachable and the container is restarted
61 // for the wrong reason.
62 status := http.StatusOK
63 if err := h.downloadSvc.Ping(ctx); err != nil {
64 resp.Status = "unhealthy"
65 resp.Dependencies["database"] = "unreachable"
66 status = http.StatusServiceUnavailable
67 } else {
68 resp.Dependencies["database"] = "ok"
69 }
70
71 for tool, version := range h.toolVersions(ctx) {
72 resp.Dependencies[tool] = version
73 }
74
75 w.Header().Set("Content-Type", "application/json")
76 w.WriteHeader(status)
77 _ = json.NewEncoder(w).Encode(resp)
78}
79
80// toolVersions returns the external tools' versions, re-probing them at most
81// once per toolCacheTTL.
82func (h *Handler) toolVersions(ctx context.Context) map[string]string {
83 h.tools.mu.Lock()
84 defer h.tools.mu.Unlock()
85
86 if h.tools.results != nil && time.Since(h.tools.at) < h.tools.ttl {
87 return h.tools.results
88 }
89
90 results := map[string]string{
91 "yt-dlp": toolVersion(ctx, h.cfg.YTDLPPath, "--version"),
92 "ffmpeg": toolVersion(ctx, h.cfg.FFmpegPath, "-version"),
93 "ffprobe": toolVersion(ctx, h.cfg.FFprobePath, "-version"),
94 }
95 ttl := toolCacheTTL
96 if slices.Contains(slices.Collect(maps.Values(results)), "timed out") {
97 ttl = toolRetryTTL
98 }
99 h.tools.results, h.tools.at, h.tools.ttl = results, time.Now(), ttl
100 return results
101}
102
103// toolVersion returns the first line of the tool's version output. It reports
104// "not installed" when the binary is missing and "timed out" when it doesn't
105// answer within ctx.
106func toolVersion(ctx context.Context, path, versionArg string) string {
107 out, err := util.KillableCommand(ctx, path, versionArg).Output()
108 if err != nil {
109 if ctx.Err() != nil {
110 return "timed out"
111 }
112 return "not installed"
113 }
114 line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
115 // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only.
116 if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "version" {
117 return fields[2]
118 }
119 return line
120}
121