github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/internal/rest/client.go (about)

     1  // Copyright (c) 2015-2021 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package rest
    19  
    20  import (
    21  	"bytes"
    22  	"context"
    23  	"errors"
    24  	"fmt"
    25  	"io"
    26  	"math/rand"
    27  	"net/http"
    28  	"net/http/httputil"
    29  	"net/url"
    30  	"path"
    31  	"strings"
    32  	"sync"
    33  	"sync/atomic"
    34  	"time"
    35  
    36  	xhttp "github.com/minio/minio/internal/http"
    37  	"github.com/minio/minio/internal/logger"
    38  	"github.com/minio/minio/internal/mcontext"
    39  	xnet "github.com/minio/pkg/v2/net"
    40  )
    41  
    42  // DefaultTimeout - default REST timeout is 10 seconds.
    43  const DefaultTimeout = 10 * time.Second
    44  
    45  const (
    46  	offline = iota
    47  	online
    48  	closed
    49  )
    50  
    51  // NetworkError - error type in case of errors related to http/transport
    52  // for ex. connection refused, connection reset, dns resolution failure etc.
    53  // All errors returned by storage-rest-server (ex errFileNotFound, errDiskNotFound) are not considered to be network errors.
    54  type NetworkError struct {
    55  	Err error
    56  }
    57  
    58  func (n *NetworkError) Error() string {
    59  	return n.Err.Error()
    60  }
    61  
    62  // Unwrap returns the error wrapped in NetworkError.
    63  func (n *NetworkError) Unwrap() error {
    64  	return n.Err
    65  }
    66  
    67  // Client - http based RPC client.
    68  type Client struct {
    69  	connected int32 // ref: https://golang.org/pkg/sync/atomic/#pkg-note-BUG
    70  	_         int32 // For 64 bits alignment
    71  	lastConn  int64
    72  
    73  	// HealthCheckFn is the function set to test for health.
    74  	// If not set the client will not keep track of health.
    75  	// Calling this returns true or false if the target
    76  	// is online or offline.
    77  	HealthCheckFn func() bool
    78  
    79  	// HealthCheckRetryUnit will be used to calculate the exponential
    80  	// backoff when trying to reconnect to an offline node
    81  	HealthCheckReconnectUnit time.Duration
    82  
    83  	// HealthCheckTimeout determines timeout for each call.
    84  	HealthCheckTimeout time.Duration
    85  
    86  	// MaxErrResponseSize is the maximum expected response size.
    87  	// Should only be modified before any calls are made.
    88  	MaxErrResponseSize int64
    89  
    90  	// Avoid metrics update if set to true
    91  	NoMetrics bool
    92  
    93  	// TraceOutput will print debug information on non-200 calls if set.
    94  	TraceOutput io.Writer // Debug trace output
    95  
    96  	httpClient   *http.Client
    97  	url          *url.URL
    98  	newAuthToken func(audience string) string
    99  
   100  	sync.RWMutex // mutex for lastErr
   101  	lastErr      error
   102  	lastErrTime  time.Time
   103  }
   104  
   105  type restError string
   106  
   107  func (e restError) Error() string {
   108  	return string(e)
   109  }
   110  
   111  func (e restError) Timeout() bool {
   112  	return true
   113  }
   114  
   115  // Given a string of the form "host", "host:port", or "[ipv6::address]:port",
   116  // return true if the string includes a port.
   117  func hasPort(s string) bool { return strings.LastIndex(s, ":") > strings.LastIndex(s, "]") }
   118  
   119  // removeEmptyPort strips the empty port in ":port" to ""
   120  // as mandated by RFC 3986 Section 6.2.3.
   121  func removeEmptyPort(host string) string {
   122  	if hasPort(host) {
   123  		return strings.TrimSuffix(host, ":")
   124  	}
   125  	return host
   126  }
   127  
   128  // Copied from http.NewRequest but implemented to ensure we reuse `url.URL` instance.
   129  func (c *Client) newRequest(ctx context.Context, u url.URL, body io.Reader) (*http.Request, error) {
   130  	rc, ok := body.(io.ReadCloser)
   131  	if !ok && body != nil {
   132  		rc = io.NopCloser(body)
   133  	}
   134  	req := &http.Request{
   135  		Method:     http.MethodPost,
   136  		URL:        &u,
   137  		Proto:      "HTTP/1.1",
   138  		ProtoMajor: 1,
   139  		ProtoMinor: 1,
   140  		Header:     make(http.Header),
   141  		Body:       rc,
   142  		Host:       u.Host,
   143  	}
   144  	req = req.WithContext(ctx)
   145  	if body != nil {
   146  		switch v := body.(type) {
   147  		case *bytes.Buffer:
   148  			req.ContentLength = int64(v.Len())
   149  			buf := v.Bytes()
   150  			req.GetBody = func() (io.ReadCloser, error) {
   151  				r := bytes.NewReader(buf)
   152  				return io.NopCloser(r), nil
   153  			}
   154  		case *bytes.Reader:
   155  			req.ContentLength = int64(v.Len())
   156  			snapshot := *v
   157  			req.GetBody = func() (io.ReadCloser, error) {
   158  				r := snapshot
   159  				return io.NopCloser(&r), nil
   160  			}
   161  		case *strings.Reader:
   162  			req.ContentLength = int64(v.Len())
   163  			snapshot := *v
   164  			req.GetBody = func() (io.ReadCloser, error) {
   165  				r := snapshot
   166  				return io.NopCloser(&r), nil
   167  			}
   168  		default:
   169  			// This is where we'd set it to -1 (at least
   170  			// if body != NoBody) to mean unknown, but
   171  			// that broke people during the Go 1.8 testing
   172  			// period. People depend on it being 0 I
   173  			// guess. Maybe retry later. See Issue 18117.
   174  		}
   175  		// For client requests, Request.ContentLength of 0
   176  		// means either actually 0, or unknown. The only way
   177  		// to explicitly say that the ContentLength is zero is
   178  		// to set the Body to nil. But turns out too much code
   179  		// depends on NewRequest returning a non-nil Body,
   180  		// so we use a well-known ReadCloser variable instead
   181  		// and have the http package also treat that sentinel
   182  		// variable to mean explicitly zero.
   183  		if req.GetBody != nil && req.ContentLength == 0 {
   184  			req.Body = http.NoBody
   185  			req.GetBody = func() (io.ReadCloser, error) { return http.NoBody, nil }
   186  		}
   187  	}
   188  
   189  	if c.newAuthToken != nil {
   190  		req.Header.Set("Authorization", "Bearer "+c.newAuthToken(u.RawQuery))
   191  	}
   192  	req.Header.Set("X-Minio-Time", time.Now().UTC().Format(time.RFC3339))
   193  
   194  	if tc, ok := ctx.Value(mcontext.ContextTraceKey).(*mcontext.TraceCtxt); ok {
   195  		req.Header.Set(xhttp.AmzRequestID, tc.AmzReqID)
   196  	}
   197  
   198  	return req, nil
   199  }
   200  
   201  type respBodyMonitor struct {
   202  	io.ReadCloser
   203  	expectTimeouts  bool
   204  	errorStatusOnce sync.Once
   205  }
   206  
   207  func (r *respBodyMonitor) Read(p []byte) (n int, err error) {
   208  	n, err = r.ReadCloser.Read(p)
   209  	r.errorStatus(err)
   210  	return
   211  }
   212  
   213  func (r *respBodyMonitor) Close() (err error) {
   214  	err = r.ReadCloser.Close()
   215  	r.errorStatus(err)
   216  	return
   217  }
   218  
   219  func (r *respBodyMonitor) errorStatus(err error) {
   220  	if xnet.IsNetworkOrHostDown(err, r.expectTimeouts) {
   221  		r.errorStatusOnce.Do(func() {
   222  			atomic.AddUint64(&globalStats.errs, 1)
   223  		})
   224  	}
   225  }
   226  
   227  // dumpHTTP - dump HTTP request and response.
   228  func (c *Client) dumpHTTP(req *http.Request, resp *http.Response) {
   229  	// Starts http dump.
   230  	_, err := fmt.Fprintln(c.TraceOutput, "---------START-HTTP---------")
   231  	if err != nil {
   232  		return
   233  	}
   234  
   235  	// Filter out Signature field from Authorization header.
   236  	origAuth := req.Header.Get("Authorization")
   237  	if origAuth != "" {
   238  		req.Header.Set("Authorization", "**REDACTED**")
   239  	}
   240  
   241  	// Only display request header.
   242  	reqTrace, err := httputil.DumpRequestOut(req, false)
   243  	if err != nil {
   244  		return
   245  	}
   246  
   247  	// Write request to trace output.
   248  	_, err = fmt.Fprint(c.TraceOutput, string(reqTrace))
   249  	if err != nil {
   250  		return
   251  	}
   252  
   253  	// Only display response header.
   254  	var respTrace []byte
   255  
   256  	// For errors we make sure to dump response body as well.
   257  	if resp.StatusCode != http.StatusOK &&
   258  		resp.StatusCode != http.StatusPartialContent &&
   259  		resp.StatusCode != http.StatusNoContent {
   260  		respTrace, err = httputil.DumpResponse(resp, true)
   261  		if err != nil {
   262  			return
   263  		}
   264  	} else {
   265  		respTrace, err = httputil.DumpResponse(resp, false)
   266  		if err != nil {
   267  			return
   268  		}
   269  	}
   270  
   271  	// Write response to trace output.
   272  	_, err = fmt.Fprint(c.TraceOutput, strings.TrimSuffix(string(respTrace), "\r\n"))
   273  	if err != nil {
   274  		return
   275  	}
   276  
   277  	// Ends the http dump.
   278  	_, err = fmt.Fprintln(c.TraceOutput, "---------END-HTTP---------")
   279  	if err != nil {
   280  		return
   281  	}
   282  
   283  	// Returns success.
   284  	return
   285  }
   286  
   287  // Call - make a REST call with context.
   288  func (c *Client) Call(ctx context.Context, method string, values url.Values, body io.Reader, length int64) (reply io.ReadCloser, err error) {
   289  	if !c.IsOnline() {
   290  		return nil, &NetworkError{Err: c.LastError()}
   291  	}
   292  
   293  	// Shallow copy. We don't modify the *UserInfo, if set.
   294  	// All other fields are copied.
   295  	u := *c.url
   296  	u.Path = path.Join(u.Path, method)
   297  	u.RawQuery = values.Encode()
   298  
   299  	req, err := c.newRequest(ctx, u, body)
   300  	if err != nil {
   301  		return nil, &NetworkError{Err: err}
   302  	}
   303  	if length > 0 {
   304  		req.ContentLength = length
   305  	}
   306  
   307  	_, expectTimeouts := ctx.Deadline()
   308  
   309  	req, update := setupReqStatsUpdate(req)
   310  	defer update()
   311  
   312  	resp, err := c.httpClient.Do(req)
   313  	if err != nil {
   314  		if xnet.IsNetworkOrHostDown(err, expectTimeouts) {
   315  			if !c.NoMetrics {
   316  				atomic.AddUint64(&globalStats.errs, 1)
   317  			}
   318  			if c.MarkOffline(err) {
   319  				logger.LogOnceIf(ctx, fmt.Errorf("Marking %s offline temporarily; caused by %w", c.url.Host, err), c.url.Host)
   320  			}
   321  		}
   322  		return nil, &NetworkError{err}
   323  	}
   324  
   325  	// If trace is enabled, dump http request and response,
   326  	// except when the traceErrorsOnly enabled and the response's status code is ok
   327  	if c.TraceOutput != nil && resp.StatusCode != http.StatusOK {
   328  		c.dumpHTTP(req, resp)
   329  	}
   330  
   331  	if resp.StatusCode != http.StatusOK {
   332  		// If server returns 412 pre-condition failed, it would
   333  		// mean that authentication succeeded, but another
   334  		// side-channel check has failed, we shall take
   335  		// the client offline in such situations.
   336  		// generally all implementations should simply return
   337  		// 403, but in situations where there is a dependency
   338  		// with the caller to take the client offline purpose
   339  		// fully it should make sure to respond with '412'
   340  		// instead, see cmd/storage-rest-server.go for ideas.
   341  		if c.HealthCheckFn != nil && resp.StatusCode == http.StatusPreconditionFailed {
   342  			err = fmt.Errorf("Marking %s offline temporarily; caused by PreconditionFailed with drive ID mismatch", c.url.Host)
   343  			logger.LogOnceIf(ctx, err, c.url.Host)
   344  			c.MarkOffline(err)
   345  		}
   346  		defer xhttp.DrainBody(resp.Body)
   347  		// Limit the ReadAll(), just in case, because of a bug, the server responds with large data.
   348  		b, err := io.ReadAll(io.LimitReader(resp.Body, c.MaxErrResponseSize))
   349  		if err != nil {
   350  			if xnet.IsNetworkOrHostDown(err, expectTimeouts) {
   351  				if !c.NoMetrics {
   352  					atomic.AddUint64(&globalStats.errs, 1)
   353  				}
   354  				if c.MarkOffline(err) {
   355  					logger.LogOnceIf(ctx, fmt.Errorf("Marking %s offline temporarily; caused by %w", c.url.Host, err), c.url.Host)
   356  				}
   357  			}
   358  			return nil, err
   359  		}
   360  		if len(b) > 0 {
   361  			return nil, errors.New(string(b))
   362  		}
   363  		return nil, errors.New(resp.Status)
   364  	}
   365  	if !c.NoMetrics {
   366  		resp.Body = &respBodyMonitor{ReadCloser: resp.Body, expectTimeouts: expectTimeouts}
   367  	}
   368  	return resp.Body, nil
   369  }
   370  
   371  // Close closes all idle connections of the underlying http client
   372  func (c *Client) Close() {
   373  	atomic.StoreInt32(&c.connected, closed)
   374  }
   375  
   376  // NewClient - returns new REST client.
   377  func NewClient(uu *url.URL, tr http.RoundTripper, newAuthToken func(aud string) string) *Client {
   378  	connected := int32(online)
   379  	urlStr := uu.String()
   380  	u, err := url.Parse(urlStr)
   381  	if err != nil {
   382  		// Mark offline, with no reconnection attempts.
   383  		connected = int32(offline)
   384  		err = &url.Error{URL: urlStr, Err: err}
   385  	}
   386  	// The host's colon:port should be normalized. See Issue 14836.
   387  	u.Host = removeEmptyPort(u.Host)
   388  
   389  	// Transport is exactly same as Go default in https://golang.org/pkg/net/http/#RoundTripper
   390  	// except custom DialContext and TLSClientConfig.
   391  	clnt := &Client{
   392  		httpClient:               &http.Client{Transport: tr},
   393  		url:                      u,
   394  		lastErr:                  err,
   395  		lastErrTime:              time.Now(),
   396  		newAuthToken:             newAuthToken,
   397  		connected:                connected,
   398  		lastConn:                 time.Now().UnixNano(),
   399  		MaxErrResponseSize:       4096,
   400  		HealthCheckReconnectUnit: 200 * time.Millisecond,
   401  		HealthCheckTimeout:       time.Second,
   402  	}
   403  	if clnt.HealthCheckFn != nil {
   404  		// make connection pre-emptively.
   405  		go clnt.HealthCheckFn()
   406  	}
   407  	return clnt
   408  }
   409  
   410  // IsOnline returns whether the client is likely to be online.
   411  func (c *Client) IsOnline() bool {
   412  	return atomic.LoadInt32(&c.connected) == online
   413  }
   414  
   415  // LastConn returns when the disk was (re-)connected
   416  func (c *Client) LastConn() time.Time {
   417  	return time.Unix(0, atomic.LoadInt64(&c.lastConn))
   418  }
   419  
   420  // LastError returns previous error
   421  func (c *Client) LastError() error {
   422  	c.RLock()
   423  	defer c.RUnlock()
   424  	return fmt.Errorf("[%s] %w", c.lastErrTime.Format(time.RFC3339), c.lastErr)
   425  }
   426  
   427  // computes the exponential backoff duration according to
   428  // https://www.awsarchitectureblog.com/2015/03/backoff.html
   429  func exponentialBackoffWait(r *rand.Rand, unit, cap time.Duration) func(uint) time.Duration {
   430  	if unit > time.Hour {
   431  		// Protect against integer overflow
   432  		panic("unit cannot exceed one hour")
   433  	}
   434  	return func(attempt uint) time.Duration {
   435  		if attempt > 16 {
   436  			// Protect against integer overflow
   437  			attempt = 16
   438  		}
   439  		// sleep = random_between(unit, min(cap, base * 2 ** attempt))
   440  		sleep := unit * time.Duration(1<<attempt)
   441  		if sleep > cap {
   442  			sleep = cap
   443  		}
   444  		sleep -= time.Duration(r.Float64() * float64(sleep-unit))
   445  		return sleep
   446  	}
   447  }
   448  
   449  func (c *Client) runHealthCheck() bool {
   450  	// Start goroutine that will attempt to reconnect.
   451  	// If server is already trying to reconnect this will have no effect.
   452  	if c.HealthCheckFn != nil && atomic.CompareAndSwapInt32(&c.connected, online, offline) {
   453  		go func() {
   454  			backOff := exponentialBackoffWait(
   455  				rand.New(rand.NewSource(time.Now().UnixNano())),
   456  				200*time.Millisecond,
   457  				30*time.Second,
   458  			)
   459  
   460  			attempt := uint(0)
   461  			for {
   462  				if atomic.LoadInt32(&c.connected) == closed {
   463  					return
   464  				}
   465  				if c.HealthCheckFn() {
   466  					if atomic.CompareAndSwapInt32(&c.connected, offline, online) {
   467  						now := time.Now()
   468  						disconnected := now.Sub(c.LastConn())
   469  						logger.Event(context.Background(), "Client '%s' re-connected in %s", c.url.String(), disconnected)
   470  						atomic.StoreInt64(&c.lastConn, now.UnixNano())
   471  					}
   472  					return
   473  				}
   474  				attempt++
   475  				time.Sleep(backOff(attempt))
   476  			}
   477  		}()
   478  		return true
   479  	}
   480  	return false
   481  }
   482  
   483  // MarkOffline - will mark a client as being offline and spawns
   484  // a goroutine that will attempt to reconnect if HealthCheckFn is set.
   485  // returns true if the node changed state from online to offline
   486  func (c *Client) MarkOffline(err error) bool {
   487  	c.Lock()
   488  	c.lastErr = err
   489  	c.lastErrTime = time.Now()
   490  	atomic.StoreInt64(&c.lastConn, time.Now().UnixNano())
   491  	c.Unlock()
   492  
   493  	return c.runHealthCheck()
   494  }