github.com/avenga/couper@v1.12.2/handler/transport/probe.go (about)

     1  package transport
     2  
     3  import (
     4  	"context"
     5  	goerror "errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"strconv"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/sirupsen/logrus"
    14  
    15  	"github.com/avenga/couper/config"
    16  	"github.com/avenga/couper/config/request"
    17  	"github.com/avenga/couper/errors"
    18  	"github.com/avenga/couper/eval"
    19  	"github.com/avenga/couper/handler/middleware"
    20  	"github.com/avenga/couper/logging"
    21  )
    22  
    23  const (
    24  	StateInvalid state = iota
    25  	StateOk
    26  	StateFailing
    27  	StateDown
    28  )
    29  
    30  var healthStateLabels = []string{
    31  	"invalid",
    32  	"healthy",
    33  	"failing",
    34  	"unhealthy",
    35  }
    36  
    37  var _ context.Context = &eval.Context{}
    38  
    39  type state int
    40  
    41  func (s state) String() string {
    42  	return healthStateLabels[s]
    43  }
    44  
    45  type HealthInfo struct {
    46  	Error   string
    47  	Healthy bool
    48  	Origin  string
    49  	State   string
    50  }
    51  
    52  type Probe struct {
    53  	//configurable settings
    54  	backendName string
    55  	log         *logrus.Entry
    56  	opts        *config.HealthCheck
    57  
    58  	//variables reflecting status of probe
    59  	client  *http.Client
    60  	counter uint
    61  	failure uint
    62  	state   state
    63  	status  int
    64  
    65  	listener ProbeStateChange
    66  
    67  	uidFunc middleware.UIDFunc
    68  }
    69  
    70  type ProbeStateChange interface {
    71  	OnProbeChange(info *HealthInfo)
    72  }
    73  
    74  func NewProbe(log *logrus.Entry, tc *Config, opts *config.HealthCheck, listener ProbeStateChange) {
    75  	// do not start go-routine on config check (-watch)
    76  	if _, exist := opts.Context.Value(request.ConfigDryRun).(bool); exist {
    77  		return
    78  	}
    79  
    80  	client := &http.Client{
    81  		CheckRedirect: func(req *http.Request, via []*http.Request) error {
    82  			return http.ErrUseLastResponse
    83  		},
    84  		Transport: logging.NewUpstreamLog(log,
    85  			NewTransport(tc.
    86  				WithTarget(opts.Request.URL.Scheme, opts.Request.URL.Host, opts.Request.URL.Host, ""),
    87  				log),
    88  			tc.NoProxyFromEnv),
    89  	}
    90  
    91  	p := &Probe{
    92  		backendName: tc.BackendName,
    93  		log:         log.WithField("url", opts.Request.URL.String()),
    94  		opts:        opts,
    95  
    96  		client: client,
    97  		state:  StateInvalid,
    98  
    99  		listener: listener,
   100  
   101  		uidFunc: middleware.NewUIDFunc(opts.RequestUIDFormat),
   102  	}
   103  
   104  	go p.probe(opts.Context)
   105  }
   106  
   107  func (p *Probe) probe(c context.Context) {
   108  	for {
   109  		select {
   110  		case <-c.Done():
   111  			p.log.Warn("shutdown health probe")
   112  			return
   113  		default:
   114  		}
   115  		ctx, cancel := context.WithTimeout(context.Background(), p.opts.Timeout)
   116  		ctx = context.WithValue(ctx, request.RoundTripName, "health-check")
   117  		uid := p.uidFunc()
   118  		ctx = context.WithValue(ctx, request.UID, uid)
   119  
   120  		res, err := p.client.Do(p.opts.Request.Clone(ctx))
   121  		cancel()
   122  
   123  		p.counter++
   124  		prevState := p.state
   125  		p.status = 0
   126  		if res != nil {
   127  			p.status = res.StatusCode
   128  		}
   129  
   130  		var errorMessage string
   131  		if err != nil || !p.opts.ExpectedStatus[res.StatusCode] || !contains(res.Body, p.opts.ExpectedText) {
   132  			if p.failure++; p.failure < p.opts.FailureThreshold {
   133  				p.state = StateFailing
   134  			} else {
   135  				p.state = StateDown
   136  			}
   137  			if err == nil {
   138  				if !p.opts.ExpectedStatus[res.StatusCode] {
   139  					errorMessage = "unexpected status code: " + strconv.Itoa(p.status)
   140  				} else {
   141  					errorMessage = "unexpected text"
   142  				}
   143  			} else {
   144  				unwrapped := goerror.Unwrap(err)
   145  				if unwrapped != nil {
   146  					err = unwrapped
   147  				}
   148  
   149  				if gerr, ok := err.(errors.GoError); ok {
   150  					// Upstream log wraps a possible transport deadline into a backend error
   151  					if gerr.Unwrap() == context.DeadlineExceeded {
   152  						errorMessage = fmt.Sprintf("backend error: connecting to %s '%s' failed: i/o timeout",
   153  							p.backendName, p.opts.Request.URL.Hostname())
   154  					} else {
   155  						errorMessage = gerr.LogError()
   156  					}
   157  				} else {
   158  					errorMessage = err.Error()
   159  				}
   160  			}
   161  		} else {
   162  			p.failure = 0
   163  			p.state = StateOk
   164  			errorMessage = ""
   165  		}
   166  
   167  		if prevState != p.state {
   168  			newState := p.state.String()
   169  			info := &HealthInfo{
   170  				Error:   errorMessage,
   171  				Healthy: p.state != StateDown,
   172  				Origin:  p.opts.Request.URL.Host,
   173  				State:   newState,
   174  			}
   175  
   176  			if p.listener != nil {
   177  				p.listener.OnProbeChange(info)
   178  			}
   179  
   180  			message := fmt.Sprintf("new health state: %s", newState)
   181  
   182  			log := p.log.WithField("uid", uid)
   183  			switch p.state {
   184  			case StateOk:
   185  				log.Info(message)
   186  			case StateFailing:
   187  				log.Warn(message)
   188  			case StateDown:
   189  				log.WithError(errors.BackendUnhealthy.Message(errorMessage + ": " + message)).Error()
   190  			}
   191  		}
   192  
   193  		time.Sleep(p.opts.Interval)
   194  	}
   195  }
   196  
   197  func (p Probe) String() string {
   198  	return fmt.Sprintf("check #%d for backend %q: state: %s (%d/%d), HTTP status: %d", p.counter, p.backendName, p.state, p.failure, p.opts.FailureThreshold, p.status)
   199  }
   200  
   201  func contains(reader io.ReadCloser, text string) bool {
   202  	defer reader.Close() // free resp body related connection
   203  
   204  	if text == "" {
   205  		return true
   206  	}
   207  
   208  	bytes, _ := io.ReadAll(reader)
   209  	return strings.Contains(string(bytes), text)
   210  }