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 }