github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/k6runner/http.go (about) 1 package k6runner 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "math/rand/v2" 10 "net/http" 11 "net/url" 12 "strings" 13 "time" 14 15 "github.com/prometheus/client_golang/prometheus" 16 "github.com/rs/zerolog" 17 ) 18 19 type HttpRunner struct { 20 url string 21 logger *zerolog.Logger 22 // backoff sets the minimum amount of time to wait before retrying a request. nth attempt waits n times this value, 23 // plus some jitter. 24 backoff time.Duration 25 // graceTime tells the HttpRunner how much time to add to the script timeout to form the request timeout. 26 graceTime time.Duration 27 // metrics stores metrics for the remote k6 runner. 28 metrics *HTTPMetrics 29 // versionPollInterval specifies how often Versions() should poll the remote k6 runner for versions and feed that to 30 // the channel. 31 versionPollInterval time.Duration 32 } 33 34 const ( 35 defaultBackoff = 10 * time.Second 36 defaultGraceTime = 20 * time.Second 37 ) 38 39 type requestError struct { 40 Err string `json:"error"` 41 Message string `json:"msg"` 42 } 43 44 var _ error = requestError{} 45 46 func (r requestError) Error() string { 47 return fmt.Sprintf("%s: %s", r.Err, r.Message) 48 } 49 50 // HTTPRunRequest 51 type HTTPRunRequest struct { 52 Script `json:",inline"` 53 SecretStore SecretStore `json:",inline"` 54 NotAfter time.Time `json:"notAfter"` 55 ExecutionID string `json:"executionId"` 56 } 57 58 type RunResponse struct { 59 Error string `json:"error,omitempty"` 60 ErrorCode string `json:"errorCode,omitempty"` 61 Metrics []byte `json:"metrics"` 62 Logs []byte `json:"logs"` 63 } 64 65 type VersionsResponse struct { 66 List []struct { 67 Version string `json:"version"` 68 Path string `json:"path"` // Unused. 69 } `json:"versions"` 70 } 71 72 func (v VersionsResponse) Versions() []string { 73 versions := make([]string, 0, len(v.List)) 74 for _, v := range v.List { 75 versions = append(versions, v.Version) 76 } 77 78 return versions 79 } 80 81 func (r HttpRunner) WithLogger(logger *zerolog.Logger) Runner { 82 r.logger = logger 83 84 return r 85 } 86 87 var ErrUnexpectedStatus = errors.New("unexpected status code") 88 89 func (r HttpRunner) Run(ctx context.Context, script Script, secretStore SecretStore, executionID string) (*RunResponse, error) { 90 if r.backoff == 0 { 91 panic("zero backoff, runner is misconfigured, refusing to DoS") 92 } 93 94 // FIXME: Make this an error after we fully roll out k6 versioning. 95 if script.K6ChannelManifest == "" || script.K6ChannelManifest == "*" { 96 r.logger.Debug().Msg("Check does not define a channel, latest k6 version will be used and it may change without warning") 97 } 98 99 if deadline, hasDeadline := ctx.Deadline(); !hasDeadline { 100 defaultAllRetriesTimeout := time.Duration(script.Settings.Timeout) * time.Millisecond * 2 101 r.logger.Error(). 102 Dur("allRetriesTimeout", defaultAllRetriesTimeout). 103 Msg("k6 runner does not have a deadline for all retries. This is a bug. Defaulting to twice the timeout to avoid retrying forever") 104 105 var cancel context.CancelFunc 106 107 ctx, cancel = context.WithTimeout(ctx, defaultAllRetriesTimeout) 108 defer cancel() 109 } else if tud := time.Until(deadline); tud < time.Duration(script.Settings.Timeout)*time.Millisecond*2 { 110 r.logger.Debug(). 111 Str("timeUntilNext", tud.String()). 112 Str("timeout", (time.Duration(script.Settings.Timeout) * time.Millisecond).String()). 113 Msg("time until next execution is too close to script timeout, there might not be room for retries") 114 } 115 116 // Retry logic is purely context (time) based, but we keep track of the number of attempts for reporting telemetry. 117 wait := r.backoff 118 119 var ( 120 attempts float64 121 response *RunResponse 122 ) 123 124 for { 125 start := time.Now() 126 127 attempts += 1 128 129 var err error 130 131 response, err = r.request(ctx, script, secretStore, executionID) 132 if err == nil { 133 r.logger.Debug().Bytes("metrics", response.Metrics).Bytes("logs", response.Logs).Msg("script result") 134 r.metrics.Requests.With(map[string]string{metricLabelSuccess: "1", metricLabelRetriable: ""}).Inc() 135 r.metrics.RequestsPerRun.WithLabelValues("1").Observe(attempts) 136 137 return response, nil 138 } 139 140 if !errors.Is(err, errRetryable) { 141 // TODO: Log the returned error in the Processor instead. 142 r.logger.Error().Err(err).Msg("non-retryable error running k6") 143 r.metrics.Requests.With(map[string]string{metricLabelSuccess: "0", metricLabelRetriable: "0"}).Inc() 144 r.metrics.RequestsPerRun.WithLabelValues("0").Observe(attempts) 145 146 return nil, err 147 } 148 149 r.metrics.Requests.With(map[string]string{metricLabelSuccess: "0", metricLabelRetriable: "1"}).Inc() 150 151 // Wait, but subtract the amount of time we've already waited as part of the request timeout. 152 // We do this because these requests have huge timeouts, and by the nature of the system running these requests, 153 // we expect the most common error to be a timeout, so we avoid waiting even more on top of an already large 154 // value. 155 waitRemaining := max(0, wait-time.Since(start)) 156 r.logger.Warn().Err(err).Dur("after", waitRemaining).Msg("retrying retryable error") 157 158 waitTimer := time.NewTimer(waitRemaining) 159 select { 160 case <-ctx.Done(): 161 waitTimer.Stop() 162 // TODO: Log the returned error in the Processor instead. 163 r.logger.Error().Err(err).Object("checkInfo", &script.CheckInfo).Msg("retries exhausted") 164 r.metrics.RequestsPerRun.WithLabelValues("0").Observe(attempts) 165 166 return nil, fmt.Errorf("cannot retry further: %w", errors.Join(err, ctx.Err())) 167 case <-waitTimer.C: 168 } 169 170 // Backoff linearly, adding some jitter. 171 wait += r.backoff + time.Duration(rand.Int64N(int64(r.backoff))) 172 } 173 } 174 175 // errRetryable indicates that an error is retryable. It is typically joined with another error. 176 var errRetryable = errors.New("retryable") 177 178 func (r HttpRunner) request(ctx context.Context, script Script, secretStore SecretStore, executionID string) (*RunResponse, error) { 179 checkTimeout := time.Duration(script.Settings.Timeout) * time.Millisecond 180 if checkTimeout == 0 { 181 return nil, ErrNoTimeout 182 } 183 184 // requestTimeout should be noticeably larger than [Script.Settings.Timeout], to account for added latencies in the 185 // system such as network, i/o, seralization, queue wait time, etc. that take place after and before the script is 186 // ran. 187 // t0 t1 t2 t3 188 // |--- Queue wait ---|-------------- k6 run -----------------|--- Response ---| 189 // checkTimeout = t2 - t1 190 // requestTimeout = t3 - t0 191 requestTimeout := checkTimeout + r.graceTime 192 notAfter := time.Now().Add(requestTimeout) 193 194 ctx, cancel := context.WithDeadline(ctx, notAfter) 195 defer cancel() 196 197 // Decorate the script request with the NotAfter hint. 198 // NotAfter hints runners when we're about to drop this request. Runners will refuse to start to run a script if 199 // this time is in the past, as it is guaranteed that we, the client, have already given up on the request. 200 // This allows runners to not waste time running scripts which will not complete before the client gives up on the 201 // request. 202 runRequest := HTTPRunRequest{ 203 Script: script, 204 SecretStore: secretStore, 205 NotAfter: notAfter, 206 ExecutionID: executionID, 207 } 208 209 reqBody, err := json.Marshal(runRequest) 210 if err != nil { 211 return nil, fmt.Errorf("encoding script: %w", err) 212 } 213 214 runURL, err := url.JoinPath(r.url, "/run") 215 if err != nil { 216 return nil, fmt.Errorf("building URL: %w", err) 217 } 218 219 req, err := http.NewRequestWithContext(ctx, http.MethodPost, runURL, bytes.NewReader(reqBody)) 220 if err != nil { 221 return nil, fmt.Errorf("building request: %w", err) 222 } 223 224 req.Header.Add("content-type", "application/json") 225 226 resp, err := http.DefaultClient.Do(req) 227 if err != nil { 228 r.logger.Error().Err(err).Msg("sending request") 229 230 // Any error making a request is retryable. 231 return nil, errors.Join(errRetryable, fmt.Errorf("making request: %w", err)) 232 } 233 234 defer resp.Body.Close() 235 236 switch resp.StatusCode { 237 case http.StatusOK, http.StatusRequestTimeout, http.StatusUnprocessableEntity, http.StatusInternalServerError: 238 // These are status code that we assume come with a machine-readable response. The response may contain an error, which is 239 // handled later. 240 // See: https://github.com/grafana/sm-k6-runner/blob/main/internal/mq/proxy.go#L215 241 242 case http.StatusBadRequest: 243 // These are status codes that do not come with a machine readable response, and are not retryable. 244 // 245 // There might be an argument to be made to retry 500s, as they can be produced by panic recovering mechanisms which 246 // _can_ be seen as a transient error. However, it is also possible for a 500 to be returned by a script that failed 247 // and also needed a lot of time to complete. For this reason, we choose to not retry 500 for the time being. 248 return nil, fmt.Errorf("%w %d", ErrUnexpectedStatus, resp.StatusCode) 249 250 default: 251 // Statuses not returned by the proxy directly are assumed to be infrastructure (e.g. ingress, k8s) related and 252 // thus marked as retriable. 253 // Runners may also return http.StatusServiceUnavailable if the browser session manager cannot be reached. We want 254 // to retry those errors, so we let the "default" block catch them. 255 return nil, errors.Join(errRetryable, fmt.Errorf("%w %d", ErrUnexpectedStatus, resp.StatusCode)) 256 } 257 258 var response RunResponse 259 260 err = json.NewDecoder(resp.Body).Decode(&response) 261 if err != nil { 262 r.logger.Error().Err(err).Msg("decoding script result") 263 return nil, fmt.Errorf("decoding script result: %w", err) 264 } 265 266 return &response, nil 267 } 268 269 func (r HttpRunner) Versions(ctx context.Context) <-chan []string { 270 if r.versionPollInterval == 0 { 271 panic("zero versionPollInterval, runner is misconfigured") 272 } 273 274 ch := make(chan []string) 275 276 go func() { 277 ticker := time.NewTicker(r.versionPollInterval) 278 defer ticker.Stop() 279 280 r.logger.Debug().Str("url", r.url).Msg("Starting to poll for versions") // TODO: This is not the actual URL. 281 282 for { 283 select { 284 case <-ctx.Done(): 285 r.logger.Err(ctx.Err()).Msg("polling for versions stopped") 286 close(ch) 287 288 return 289 case <-ticker.C: 290 err := func() error { // Wrap in function so we can use conventional defer and if-err-nil-return 291 rCtx, cancel := context.WithTimeout(ctx, 3*time.Second) 292 defer cancel() 293 294 versionsURL, err := url.JoinPath(r.url, "/versions") 295 if err != nil { 296 return fmt.Errorf("building URL: %w", err) 297 } 298 299 req, err := http.NewRequestWithContext(rCtx, http.MethodGet, versionsURL, nil) 300 if err != nil { 301 return fmt.Errorf("building request: %w", err) 302 } 303 304 req.Header.Add("accept", "application/json") 305 306 resp, err := http.DefaultClient.Do(req) 307 if err != nil { 308 return fmt.Errorf("making request: %w", err) 309 } 310 311 defer resp.Body.Close() 312 313 if resp.StatusCode != 200 { 314 return fmt.Errorf("unexpected status %d", resp.StatusCode) 315 } 316 317 response := VersionsResponse{} 318 319 err = json.NewDecoder(resp.Body).Decode(&response) 320 if err != nil { 321 return fmt.Errorf("decoding response: %w", err) 322 } 323 324 versions := response.Versions() 325 326 r.logger.Debug().Strs("versions", versions).Msg("Polled remote runner for versions") 327 328 select { 329 case <-ctx.Done(): 330 return nil // This error will be caught and reported in the outer loop. 331 case <-time.After(3 * time.Second): 332 r.logger.Warn().Msg("Version report went unconsumed, discarding report and continuing") 333 return nil 334 case ch <- versions: 335 return nil 336 } 337 }() 338 if err != nil { 339 r.logger.Error().Err(err).Msg("requesting versions") 340 continue 341 } 342 } 343 } 344 }() 345 346 return ch 347 } 348 349 type HTTPMetrics struct { 350 Requests *prometheus.CounterVec 351 RequestsPerRun *prometheus.HistogramVec 352 } 353 354 const ( 355 metricLabelSuccess = "success" 356 metricLabelRetriable = "retriable" 357 ) 358 359 func NewHTTPMetrics(registerer prometheus.Registerer) *HTTPMetrics { 360 m := &HTTPMetrics{} 361 m.Requests = prometheus.NewCounterVec( 362 prometheus.CounterOpts{ 363 Namespace: "sm_agent", 364 Subsystem: "k6runner", 365 Name: "requests_total", 366 Help: "Total number of requests made to remote k6 runners, which may be more than one per run. " + 367 "The 'success' label is 1 if this single request succeeded, 0 otherwise. " + 368 "The 'retriable' label is 1 if the request failed with a retriable error, 0 otherwise. " + 369 "Successful requests do not have the 'retriable' label.", 370 }, 371 []string{metricLabelSuccess, metricLabelRetriable}, 372 ) 373 registerer.MustRegister(m.Requests) 374 375 m.RequestsPerRun = prometheus.NewHistogramVec( 376 prometheus.HistogramOpts{ 377 Namespace: "sm_agent", 378 Subsystem: "k6runner", 379 Name: "requests_per_run", 380 Help: "Number of requests attempted per run operation. " + 381 "The 'success' label is 1 if one ultimately succeeded potentially including retries, 0 otherwise.", 382 // Generally we expect request to be retries a handful of times, so we create high resolution buckets up to 383 // 5. Form 5 onwards something off is going on and we do not care that much about resolution. 384 Buckets: []float64{1, 2, 3, 4, 5, 10, 20, 50}, 385 }, 386 []string{metricLabelSuccess}, 387 ) 388 registerer.MustRegister(m.RequestsPerRun) 389 390 return m 391 } 392 393 func trimRunSuffix(rawUrl string) string { 394 u, err := url.Parse(rawUrl) 395 if err != nil { 396 // This URL is not parseable, do not attempt to replace anything. It will error somewhere else down the line. 397 return rawUrl 398 } 399 400 u.Path = strings.TrimSuffix(u.Path, "/run/") 401 u.Path = strings.TrimSuffix(u.Path, "/run") 402 403 // This may leave the URL with an empty path (no sole "/"). This is okay as per 404 // https://www.rfc-editor.org/rfc/rfc3986#section-3.3 405 406 return u.String() 407 }