1package container
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "net/http"
9 "time"
10
11 "github.com/bnema/zerowrap"
12)
13
14var errProbeTimeout = errors.New("probe timeout")
15
16func probeLoop(
17 ctx context.Context,
18 deadline time.Time,
19 baseSleep time.Duration,
20 defaultAttemptTimeout time.Duration,
21 work func(attemptCtx context.Context) (success bool, err error),
22) error {
23 for {
24 if err := ctx.Err(); err != nil {
25 return err
26 }
27 if time.Now().After(deadline) {
28 return errProbeTimeout
29 }
30
31 remaining := time.Until(deadline)
32 attemptTimeout := defaultAttemptTimeout
33 if remaining < attemptTimeout {
34 attemptTimeout = remaining
35 }
36
37 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
38 success, err := work(attemptCtx)
39 cancel()
40 if err != nil {
41 return err
42 }
43 if success {
44 return nil
45 }
46
47 sleepInterval := baseSleep
48 if remProbe := time.Until(deadline); remProbe < sleepInterval {
49 sleepInterval = remProbe
50 }
51 if ctxDeadline, ok := ctx.Deadline(); ok {
52 if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval {
53 sleepInterval = remCtx
54 }
55 }
56 if sleepInterval <= 0 {
57 if ctx.Err() != nil {
58 return ctx.Err()
59 }
60 return errProbeTimeout
61 }
62
63 t := time.NewTimer(sleepInterval)
64 select {
65 case <-t.C:
66 case <-ctx.Done():
67 t.Stop()
68 return ctx.Err()
69 }
70 }
71}
72
73// tcpProbe attempts a TCP connection to addr, retrying every 500ms until
74// success or timeout. This is the universal fallback readiness check —
75// it verifies the process is at least accepting connections.
76func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
77 log := zerowrap.FromCtx(ctx)
78 deadline := time.Now().Add(timeout)
79 dialer := &net.Dialer{}
80 attempts := 0
81 var lastErr error
82 err := probeLoop(ctx, deadline, 500*time.Millisecond, time.Second, func(attemptCtx context.Context) (bool, error) {
83 conn, err := dialer.DialContext(attemptCtx, "tcp", addr)
84 attempts++
85 if err == nil {
86 conn.Close()
87 log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
88 return true, nil
89 }
90
91 lastErr = err
92 if attempts <= 3 || attempts%10 == 0 {
93 log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed")
94 }
95 return false, nil
96 })
97 if errors.Is(err, errProbeTimeout) {
98 return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
99 }
100 return err
101}
102
103// httpProbe performs HTTP GET requests to url, retrying every 1s until a
104// 2xx/3xx response or timeout. Used when gordon.health label is set.
105func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
106 client := &http.Client{
107 CheckRedirect: func(req *http.Request, via []*http.Request) error {
108 // Don't follow redirects — treat 3xx as a successful response.
109 return http.ErrUseLastResponse
110 },
111 }
112 deadline := time.Now().Add(timeout)
113 var lastStatus int
114 err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) {
115 req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)
116 if reqErr != nil {
117 return false, reqErr
118 }
119
120 resp, err := client.Do(req)
121 if err != nil {
122 return false, nil
123 }
124
125 lastStatus = resp.StatusCode
126 resp.Body.Close()
127 if lastStatus >= 200 && lastStatus < 400 {
128 return true, nil
129 }
130
131 return false, nil
132 })
133 if errors.Is(err, errProbeTimeout) {
134 return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
135 }
136 return err
137}
138
139// httpAliveProbe performs HTTP GET requests to url, retrying every 1s until
140// any HTTP response is received or timeout. Unlike httpProbe, ANY status code
141// counts as success -- the goal is to verify the HTTP server is accepting and
142// processing requests, not that a specific endpoint is healthy.
143// Used as the default readiness check for web containers.
144func httpAliveProbe(ctx context.Context, url string, timeout time.Duration) error {
145 client := &http.Client{
146 CheckRedirect: func(req *http.Request, via []*http.Request) error {
147 return http.ErrUseLastResponse
148 },
149 }
150 deadline := time.Now().Add(timeout)
151 err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) {
152 req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)
153 if reqErr != nil {
154 return false, reqErr
155 }
156
157 resp, err := client.Do(req)
158 if err != nil {
159 // Connection refused, timeout, etc. -- not ready yet.
160 return false, nil
161 }
162 resp.Body.Close()
163 // Any HTTP response means the server is alive and processing.
164 return true, nil
165 })
166 if errors.Is(err, errProbeTimeout) {
167 return fmt.Errorf("HTTP alive probe timeout after %s: no HTTP response from %s", timeout, url)
168 }
169 return err
170}