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 keeps the endpoint answering promptly with a wedged tool.
17const healthProbeTimeout = 3 * time.Second
18
19// toolCacheTTL: /healthz is public and each lookup forks a process, so without a
20// cache anyone who reaches the port costs the host three processes per request.
21const toolCacheTTL = time.Minute
22
23// toolRetryTTL applies when a probe timed out, which says less about the tool
24// than a real answer. Still cached, or a wedged tool hands an unauthenticated
25// caller three forks per request.
26const toolRetryTTL = 10 * time.Second
27
28// toolCache holds the last version probe. The mutex is held across the probe
29// itself, so a burst of requests collapses into one set of forks.
30type toolCache struct {
31 mu sync.Mutex
32 at time.Time
33 ttl time.Duration
34 results map[string]string
35}
36
37type healthResponse struct {
38 Status string `json:"status"`
39 Dependencies map[string]string `json:"dependencies"`
40}
41
42// Health returns 503 only when the database is unreachable, so a container
43// healthcheck can act on it. A missing yt-dlp or ffmpeg is reported but passes:
44// the UI still works, only downloads are affected.
45func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
46 // An orchestrator reads a stuck request as "still starting", so the whole
47 // probe is bounded.
48 ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout)
49 defer cancel()
50
51 resp := healthResponse{Status: "ok", Dependencies: make(map[string]string, 4)}
52
53 // The database goes first because it alone decides the status code, and it
54 // must get the timeout budget before a wedged tool spends it. Otherwise a slow
55 // yt-dlp gets the container restarted for a database that was fine.
56 status := http.StatusOK
57 if err := h.downloadSvc.Ping(ctx); err != nil {
58 resp.Status = "unhealthy"
59 resp.Dependencies["database"] = "unreachable"
60 status = http.StatusServiceUnavailable
61 } else {
62 resp.Dependencies["database"] = "ok"
63 }
64
65 for tool, version := range h.toolVersions(ctx) {
66 resp.Dependencies[tool] = version
67 }
68
69 w.Header().Set("Content-Type", "application/json")
70 w.WriteHeader(status)
71 _ = json.NewEncoder(w).Encode(resp)
72}
73
74func (h *Handler) toolVersions(ctx context.Context) map[string]string {
75 h.tools.mu.Lock()
76 defer h.tools.mu.Unlock()
77
78 if h.tools.results != nil && time.Since(h.tools.at) < h.tools.ttl {
79 return h.tools.results
80 }
81
82 results := map[string]string{
83 "yt-dlp": toolVersion(ctx, h.cfg.YTDLPPath, "--version"),
84 "ffmpeg": toolVersion(ctx, h.cfg.FFmpegPath, "-version"),
85 "ffprobe": toolVersion(ctx, h.cfg.FFprobePath, "-version"),
86 }
87 ttl := toolCacheTTL
88 if slices.Contains(slices.Collect(maps.Values(results)), "timed out") {
89 ttl = toolRetryTTL
90 }
91 h.tools.results, h.tools.at, h.tools.ttl = results, time.Now(), ttl
92 return results
93}
94
95// toolVersion reports "not installed" for a missing binary and "timed out" when
96// the tool does not answer within ctx.
97func toolVersion(ctx context.Context, path, versionArg string) string {
98 out, err := util.KillableCommand(ctx, path, versionArg).Output()
99 if err != nil {
100 if ctx.Err() != nil {
101 return "timed out"
102 }
103 return "not installed"
104 }
105 line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
106 // ffmpeg prints "ffmpeg version N-x ..."; keep the version token only.
107 if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "version" {
108 return fields[2]
109 }
110 return line
111}
112