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  }