golang.org/x/build@v0.0.0-20240506185731-218518f32b70/maintner/netsource.go (about) 1 // Copyright 2017 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package maintner 6 7 import ( 8 "bytes" 9 "context" 10 "crypto/sha256" 11 "encoding/json" 12 "errors" 13 "fmt" 14 "io" 15 "io/fs" 16 "log" 17 "net" 18 "net/http" 19 "net/url" 20 "os" 21 "path/filepath" 22 "strconv" 23 "strings" 24 "time" 25 26 "github.com/golang/protobuf/proto" 27 "golang.org/x/build/maintner/internal/robustio" 28 "golang.org/x/build/maintner/maintpb" 29 "golang.org/x/build/maintner/reclog" 30 ) 31 32 // NewNetworkMutationSource returns a mutation source from a master server. 33 // The server argument should be a URL to the JSON logs index. 34 func NewNetworkMutationSource(server, cacheDir string) MutationSource { 35 base, err := url.Parse(server) 36 if err != nil { 37 panic(fmt.Sprintf("invalid URL: %q", server)) 38 } 39 return &netMutSource{ 40 server: server, 41 base: base, 42 cacheDir: cacheDir, 43 } 44 } 45 46 // TailNetworkMutationSource calls fn for all new mutations added to the log on server. 47 // Events with the End field set to true are not sent, so all events will 48 // have exactly one of Mutation or Err fields set to a non-zero value. 49 // It ignores prior events. 50 // If the server is restarted and its history diverges, 51 // TailNetworkMutationSource may return duplicate events. This therefore does not 52 // return a MutationSource, so it can't be accidentally misused for important things. 53 // TailNetworkMutationSource returns if fn returns an error, if ctx expires, 54 // or if it runs into a network error. 55 func TailNetworkMutationSource(ctx context.Context, server string, fn func(MutationStreamEvent) error) error { 56 td, err := os.MkdirTemp("", "maintnertail") 57 if err != nil { 58 return err 59 } 60 defer robustio.RemoveAll(td) 61 62 ns := NewNetworkMutationSource(server, td).(*netMutSource) 63 ns.quiet = true 64 getSegs := func(waitSizeNot int64) ([]LogSegmentJSON, error) { 65 for { 66 segs, err := ns.getServerSegments(ctx, waitSizeNot) 67 if err != nil { 68 if err := ctx.Err(); err != nil { 69 return nil, err 70 } 71 // Sleep a minimum of 5 seconds before trying 72 // again. The user's fn function might sleep 73 // longer or shorter. 74 timer := time.NewTimer(5 * time.Second) 75 err := fn(MutationStreamEvent{Err: err}) 76 if err != nil { 77 timer.Stop() 78 return nil, err 79 } 80 <-timer.C 81 continue 82 } 83 return segs, nil 84 } 85 } 86 87 // See how long the log is at start. Then we'll only fetch 88 // things after that. 89 segs, err := getSegs(0) 90 if err != nil { 91 return err 92 } 93 segSize := sumJSONSegSize(segs) 94 lastSeg := segs[len(segs)-1] 95 if _, _, err := ns.syncSeg(ctx, lastSeg); err != nil { 96 return err 97 } 98 99 ticker := time.NewTicker(time.Second) // max re-fetch interval 100 defer ticker.Stop() 101 for { 102 segs, err := getSegs(segSize) 103 if err != nil { 104 return err 105 } 106 segSize = sumJSONSegSize(segs) 107 108 for _, seg := range segs { 109 if seg.Number < lastSeg.Number { 110 continue 111 } 112 var off int64 113 if seg.Number == lastSeg.Number { 114 off = lastSeg.Size 115 } 116 _, newData, err := ns.syncSeg(ctx, seg) 117 if err != nil { 118 return err 119 } 120 if err := reclog.ForeachRecord(bytes.NewReader(newData), off, func(off int64, hdr, rec []byte) error { 121 m := new(maintpb.Mutation) 122 if err := proto.Unmarshal(rec, m); err != nil { 123 return err 124 } 125 return fn(MutationStreamEvent{Mutation: m}) 126 }); err != nil { 127 return err 128 } 129 } 130 lastSeg = segs[len(segs)-1] 131 132 <-ticker.C 133 } 134 } 135 136 type netMutSource struct { 137 server string 138 base *url.URL 139 cacheDir string 140 141 last []fileSeg 142 quiet bool // disable verbose logging 143 144 // Hooks for testing. If nil, unused: 145 testHookGetServerSegments func(context.Context, int64) ([]LogSegmentJSON, error) 146 testHookSyncSeg func(context.Context, LogSegmentJSON) (fileSeg, []byte, error) 147 testHookOnSplit func(sumCommon int64) 148 testHookFilePrefixSum224 func(file string, n int64) string 149 } 150 151 func (ns *netMutSource) GetMutations(ctx context.Context) <-chan MutationStreamEvent { 152 ch := make(chan MutationStreamEvent, 50) 153 go func() { 154 err := ns.fetchAndSendMutations(ctx, ch) 155 final := MutationStreamEvent{Err: err} 156 if err == nil { 157 final.End = true 158 } 159 select { 160 case ch <- final: 161 case <-ctx.Done(): 162 } 163 }() 164 return ch 165 } 166 167 // isNoInternetError reports whether the provided error is because there's no 168 // network connectivity. 169 func isNoInternetError(err error) bool { 170 if err == nil { 171 return false 172 } 173 switch err := err.(type) { 174 case fetchError: 175 return isNoInternetError(err.Err) 176 case *url.Error: 177 return isNoInternetError(err.Err) 178 case *net.OpError: 179 return isNoInternetError(err.Err) 180 case *net.DNSError: 181 // Trashy: 182 return err.Err == "no such host" 183 default: 184 log.Printf("Unknown error type %T: %#v", err, err) 185 return false 186 } 187 } 188 189 func (ns *netMutSource) locallyCachedSegments() (segs []fileSeg, err error) { 190 defer func() { 191 if err != nil { 192 log.Printf("No network connection and failed to use local cache: %v", err) 193 } else { 194 log.Printf("No network connection; using %d locally cached segments.", len(segs)) 195 } 196 }() 197 des, err := os.ReadDir(ns.cacheDir) 198 if err != nil { 199 return nil, err 200 } 201 fiMap := map[string]fs.FileInfo{} 202 segHex := map[int]string{} 203 segGrowing := map[int]bool{} 204 for _, de := range des { 205 name := de.Name() 206 if !strings.HasSuffix(name, ".mutlog") { 207 continue 208 } 209 fi, err := de.Info() 210 if err != nil { 211 return nil, err 212 } 213 fiMap[name] = fi 214 215 if len(name) == len("0000.6897fab4d3afcda332424b2a2a1a4469021074282bc7be5606aaa221.mutlog") { 216 num, err := strconv.Atoi(name[:4]) 217 if err != nil { 218 continue 219 } 220 segHex[num] = strings.TrimSuffix(name[5:], ".mutlog") 221 } else if strings.HasSuffix(name, ".growing.mutlog") { 222 num, err := strconv.Atoi(name[:4]) 223 if err != nil { 224 continue 225 } 226 segGrowing[num] = true 227 } 228 } 229 for num := 0; ; num++ { 230 if hex, ok := segHex[num]; ok { 231 name := fmt.Sprintf("%04d.%s.mutlog", num, hex) 232 segs = append(segs, fileSeg{ 233 seg: num, 234 file: filepath.Join(ns.cacheDir, name), 235 size: fiMap[name].Size(), 236 sha224: hex, 237 }) 238 continue 239 } 240 if segGrowing[num] { 241 name := fmt.Sprintf("%04d.growing.mutlog", num) 242 slurp, err := robustio.ReadFile(filepath.Join(ns.cacheDir, name)) 243 if err != nil { 244 return nil, err 245 } 246 segs = append(segs, fileSeg{ 247 seg: num, 248 file: filepath.Join(ns.cacheDir, name), 249 size: int64(len(slurp)), 250 sha224: fmt.Sprintf("%x", sha256.Sum224(slurp)), 251 }) 252 } 253 return segs, nil 254 } 255 } 256 257 // getServerSegments fetches the JSON logs handler (ns.server, usually 258 // https://maintner.golang.org/logs) and returns the parsed JSON. 259 // It sends the "waitsizenot" URL parameter, which specifies that the 260 // request should long-poll waiting for the server to have a sum of 261 // log segment sizes different than the value specified. As a result, 262 // it blocks until the server has new data to send or ctx expires. 263 // 264 // getServerSegments returns an error that matches fetchError with 265 // PossiblyRetryable set to true when it has signal that repeating 266 // the same call after some time may succeed. 267 func (ns *netMutSource) getServerSegments(ctx context.Context, waitSizeNot int64) ([]LogSegmentJSON, error) { 268 if fn := ns.testHookGetServerSegments; fn != nil { 269 return fn(ctx, waitSizeNot) 270 } 271 logsURL := fmt.Sprintf("%s?waitsizenot=%d", ns.server, waitSizeNot) 272 for { 273 req, err := http.NewRequestWithContext(ctx, "GET", logsURL, nil) 274 if err != nil { 275 return nil, err 276 } 277 res, err := http.DefaultClient.Do(req) 278 if err != nil { 279 return nil, fetchError{Err: err, PossiblyRetryable: true} 280 } 281 // When we're doing a long poll and the server replies 282 // with a 304 response, that means the server is just 283 // heart-beating us and trying to get a response back 284 // within its various deadlines. But we should just 285 // try again. 286 if res.StatusCode == http.StatusNotModified { 287 res.Body.Close() 288 continue 289 } 290 defer res.Body.Close() 291 if res.StatusCode/100 == 5 { 292 // Consider a 5xx server response to possibly succeed later. 293 return nil, fetchError{Err: fmt.Errorf("%s: %v", ns.server, res.Status), PossiblyRetryable: true} 294 } else if res.StatusCode != http.StatusOK { 295 return nil, fmt.Errorf("%s: %v", ns.server, res.Status) 296 } 297 b, err := io.ReadAll(res.Body) 298 if err != nil { 299 return nil, fetchError{Err: err, PossiblyRetryable: true} 300 } 301 var segs []LogSegmentJSON 302 err = json.Unmarshal(b, &segs) 303 if err != nil { 304 return nil, fmt.Errorf("unmarshaling %s JSON: %v", ns.server, err) 305 } 306 return segs, nil 307 } 308 } 309 310 // getNewSegments fetches new mutations from the network mutation source. 311 // It tries to absorb the expected network bumps by trying multiple times, 312 // and returns an error only when it considers the problem to be terminal. 313 // 314 // If there's no internet connectivity from the start, it returns locally 315 // cached segments that might be available from before. Otherwise it waits 316 // for internet connectivity to come back and keeps going when it does. 317 func (ns *netMutSource) getNewSegments(ctx context.Context) ([]fileSeg, error) { 318 sumLast := sumSegSize(ns.last) 319 320 // First, fetch JSON metadata for the segments from the server. 321 var serverSegs []LogSegmentJSON 322 for try := 1; ; { 323 segs, err := ns.getServerSegments(ctx, sumLast) 324 if isNoInternetError(err) { 325 if sumLast == 0 { 326 return ns.locallyCachedSegments() 327 } 328 log.Printf("No internet; blocking.") 329 select { 330 case <-ctx.Done(): 331 return nil, ctx.Err() 332 case <-time.After(15 * time.Second): 333 try = 1 334 continue 335 } 336 } else if fe := (fetchError{}); errors.As(err, &fe) && fe.PossiblyRetryable { 337 // Fetching the JSON logs handler happens over an unreliable network connection, 338 // and will fail at some point. Prefer to try again over reporting a terminal error. 339 const maxTries = 5 340 if try == maxTries { 341 // At this point, promote it to a terminal error. 342 return nil, fmt.Errorf("after %d attempts, fetching server segments still failed: %v", maxTries, err) 343 } 344 someDelay := time.Duration(try*try) * time.Second 345 log.Printf("fetching server segments did not succeed on attempt %d, will try again in %v: %v", try, someDelay, err) 346 select { 347 case <-ctx.Done(): 348 return nil, ctx.Err() 349 case <-time.After(someDelay): 350 try++ 351 continue 352 } 353 } else if err != nil { 354 return nil, err 355 } 356 serverSegs = segs 357 break 358 } 359 // TODO: optimization: if already on GCE, skip sync to disk part and just 360 // read from network. fast & free network inside. 361 362 // Second, fetch the new segments or their fragments 363 // that we don't yet have locally. 364 var fileSegs []fileSeg 365 for _, seg := range serverSegs { 366 for try := 1; ; { 367 fileSeg, _, err := ns.syncSeg(ctx, seg) 368 if isNoInternetError(err) { 369 log.Printf("No internet; blocking.") 370 select { 371 case <-ctx.Done(): 372 return nil, ctx.Err() 373 case <-time.After(15 * time.Second): 374 try = 1 375 continue 376 } 377 } else if fe := (fetchError{}); errors.As(err, &fe) && fe.PossiblyRetryable { 378 // Syncing a segment fetches a good deal of data over a network connection, 379 // and will fail at some point. Be very willing to try again at this layer, 380 // since it's much more efficient than having GetMutations return an error 381 // and possibly cause a higher level retry to redo significantly more work. 382 const maxTries = 10 383 if try == maxTries { 384 // At this point, promote it to a terminal error. 385 return nil, fmt.Errorf("after %d attempts, syncing segment %d still failed: %v", maxTries, seg.Number, err) 386 } 387 someDelay := time.Duration(try*try) * time.Second 388 log.Printf("syncing segment %d did not succeed on attempt %d, will try again in %v: %v", seg.Number, try, someDelay, err) 389 select { 390 case <-ctx.Done(): 391 return nil, ctx.Err() 392 case <-time.After(someDelay): 393 try++ 394 continue 395 } 396 } else if err != nil { 397 return nil, err 398 } 399 fileSegs = append(fileSegs, fileSeg) 400 break 401 } 402 } 403 404 // Verify consistency of newly fetched data, 405 // and check there is in fact something new. 406 sumCommon := ns.sumCommonPrefixSize(fileSegs, ns.last) 407 if sumCommon != sumLast { 408 if fn := ns.testHookOnSplit; fn != nil { 409 fn(sumCommon) 410 } 411 // Our history diverged from the source. 412 return nil, ErrSplit 413 } else if sumCur := sumSegSize(fileSegs); sumCommon == sumCur { 414 // Nothing new. This shouldn't happen since the maintnerd server is required to handle 415 // the "?waitsizenot=NNN" long polling parameter, so it's a problem if we get here. 416 return nil, fmt.Errorf("maintner.netsource: maintnerd server returned unchanged log segments") 417 } 418 ns.last = fileSegs 419 420 newSegs := trimLeadingSegBytes(fileSegs, sumCommon) 421 return newSegs, nil 422 } 423 424 func trimLeadingSegBytes(in []fileSeg, trim int64) []fileSeg { 425 // First trim off whole segments, sharing the same underlying memory. 426 for len(in) > 0 && trim >= in[0].size { 427 trim -= in[0].size 428 in = in[1:] 429 } 430 if len(in) == 0 { 431 return nil 432 } 433 // Now copy, since we'll be modifying the first element. 434 out := append([]fileSeg(nil), in...) 435 out[0].skip = trim 436 return out 437 } 438 439 // filePrefixSum224 returns the lowercase hex SHA-224 of the first n bytes of file. 440 func (ns *netMutSource) filePrefixSum224(file string, n int64) string { 441 if fn := ns.testHookFilePrefixSum224; fn != nil { 442 return fn(file, n) 443 } 444 f, err := os.Open(file) 445 if err != nil { 446 if !os.IsNotExist(err) { 447 log.Print(err) 448 } 449 return "" 450 } 451 defer f.Close() 452 h := sha256.New224() 453 _, err = io.CopyN(h, f, n) 454 if err != nil { 455 log.Print(err) 456 return "" 457 } 458 return fmt.Sprintf("%x", h.Sum(nil)) 459 } 460 461 func sumSegSize(segs []fileSeg) (sum int64) { 462 for _, seg := range segs { 463 sum += seg.size 464 } 465 return 466 } 467 468 func sumJSONSegSize(segs []LogSegmentJSON) (sum int64) { 469 for _, seg := range segs { 470 sum += seg.Size 471 } 472 return 473 } 474 475 // sumCommonPrefixSize computes the size of the longest common prefix of file segments a and b 476 // that can be found quickly by checking for matching checksums between segment boundaries. 477 func (ns *netMutSource) sumCommonPrefixSize(a, b []fileSeg) (sum int64) { 478 for len(a) > 0 && len(b) > 0 { 479 sa, sb := a[0], b[0] 480 if sa.sha224 == sb.sha224 { 481 // Whole chunk in common. 482 sum += sa.size 483 a, b = a[1:], b[1:] 484 continue 485 } 486 if sa.size == sb.size { 487 // If they're the same size but different 488 // sums, it must've forked. 489 return 490 } 491 // See if one chunk is a prefix of the other. 492 // Make sa be the smaller one. 493 if sb.size < sa.size { 494 sa, sb = sb, sa 495 } 496 // Hash the beginning of the bigger size. 497 bPrefixSum := ns.filePrefixSum224(sb.file, sa.size) 498 if bPrefixSum == sa.sha224 { 499 sum += sa.size 500 } 501 break 502 } 503 return 504 } 505 506 // fetchAndSendMutations fetches new mutations from the network mutation source 507 // and sends them to ch. 508 func (ns *netMutSource) fetchAndSendMutations(ctx context.Context, ch chan<- MutationStreamEvent) error { 509 newSegs, err := ns.getNewSegments(ctx) 510 if err != nil { 511 return err 512 } 513 return foreachFileSeg(newSegs, func(seg fileSeg) error { 514 f, err := os.Open(seg.file) 515 if err != nil { 516 return err 517 } 518 defer f.Close() 519 if seg.skip > 0 { 520 if _, err := f.Seek(seg.skip, io.SeekStart); err != nil { 521 return err 522 } 523 } 524 return reclog.ForeachRecord(io.LimitReader(f, seg.size-seg.skip), seg.skip, func(off int64, hdr, rec []byte) error { 525 m := new(maintpb.Mutation) 526 if err := proto.Unmarshal(rec, m); err != nil { 527 return err 528 } 529 select { 530 case ch <- MutationStreamEvent{Mutation: m}: 531 return nil 532 case <-ctx.Done(): 533 return ctx.Err() 534 } 535 }) 536 }) 537 } 538 539 func foreachFileSeg(segs []fileSeg, fn func(seg fileSeg) error) error { 540 for _, seg := range segs { 541 if err := fn(seg); err != nil { 542 return err 543 } 544 } 545 return nil 546 } 547 548 // TODO: add a constructor for this? or simplify it. make it Size + 549 // File + embedded LogSegmentJSON? 550 type fileSeg struct { 551 seg int 552 file string // full path 553 sha224 string 554 skip int64 555 size int64 556 } 557 558 // syncSeg syncs the provided log segment, returning its on-disk metadata. 559 // The newData result is the new data that was added to the segment in this sync. 560 // 561 // syncSeg returns an error that matches fetchError with PossiblyRetryable set 562 // to true when it has signal that repeating the same call after some time may 563 // succeed. 564 func (ns *netMutSource) syncSeg(ctx context.Context, seg LogSegmentJSON) (_ fileSeg, newData []byte, _ error) { 565 if fn := ns.testHookSyncSeg; fn != nil { 566 return fn(ctx, seg) 567 } 568 569 isFinalSeg := !strings.HasPrefix(seg.URL, "https://storage.googleapis.com/") 570 relURL, err := url.Parse(seg.URL) 571 if err != nil { 572 return fileSeg{}, nil, err 573 } 574 segURL := ns.base.ResolveReference(relURL) 575 576 frozen := filepath.Join(ns.cacheDir, fmt.Sprintf("%04d.%s.mutlog", seg.Number, seg.SHA224)) 577 578 // Do we already have it? Files named in their final form with the sha224 are considered 579 // complete and immutable. 580 if fi, err := os.Stat(frozen); err == nil && fi.Size() == seg.Size { 581 return fileSeg{seg: seg.Number, file: frozen, size: fi.Size(), sha224: seg.SHA224}, nil, nil 582 } 583 584 // See how much data we already have in the partial growing file. 585 partial := filepath.Join(ns.cacheDir, fmt.Sprintf("%04d.growing.mutlog", seg.Number)) 586 have, _ := robustio.ReadFile(partial) 587 if int64(len(have)) == seg.Size { 588 got224 := fmt.Sprintf("%x", sha256.Sum224(have)) 589 if got224 == seg.SHA224 { 590 if !isFinalSeg { 591 // This was growing for us, but the server started a new growing segment. 592 if err := robustio.Rename(partial, frozen); err != nil { 593 return fileSeg{}, nil, err 594 } 595 return fileSeg{seg: seg.Number, file: frozen, sha224: seg.SHA224, size: seg.Size}, nil, nil 596 } 597 return fileSeg{seg: seg.Number, file: partial, sha224: seg.SHA224, size: seg.Size}, nil, nil 598 } 599 } 600 601 // Otherwise, download new data. 602 if int64(len(have)) < seg.Size { 603 req, err := http.NewRequestWithContext(ctx, "GET", segURL.String(), nil) 604 if err != nil { 605 return fileSeg{}, nil, err 606 } 607 req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", len(have), seg.Size-1)) 608 609 if !ns.quiet { 610 log.Printf("Downloading %d bytes of %s ...", seg.Size-int64(len(have)), segURL) 611 } 612 res, err := http.DefaultClient.Do(req) 613 if err != nil { 614 return fileSeg{}, nil, fetchError{Err: err, PossiblyRetryable: true} 615 } 616 defer res.Body.Close() 617 if res.StatusCode/100 == 5 { 618 // Consider a 5xx server response to possibly succeed later. 619 return fileSeg{}, nil, fetchError{Err: fmt.Errorf("%s: %s", segURL.String(), res.Status), PossiblyRetryable: true} 620 } else if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusPartialContent { 621 return fileSeg{}, nil, fmt.Errorf("%s: %s", segURL.String(), res.Status) 622 } 623 newData, err = io.ReadAll(res.Body) 624 res.Body.Close() 625 if err != nil { 626 return fileSeg{}, nil, fetchError{Err: err, PossiblyRetryable: true} 627 } 628 } 629 630 // Commit to disk. 631 var newContents []byte 632 if int64(len(newData)) == seg.Size { 633 newContents = newData 634 } else if int64(len(have)+len(newData)) == seg.Size { 635 newContents = append(have, newData...) 636 } else if int64(len(have)) > seg.Size { 637 // We have more data than the server; likely because it restarted with uncommitted 638 // transactions, and so we're headed towards an ErrSplit. Reuse the longest common 639 // prefix as long as its checksum matches. 640 newContents = have[:seg.Size] 641 } 642 got224 := fmt.Sprintf("%x", sha256.Sum224(newContents)) 643 if got224 != seg.SHA224 { 644 if len(have) == 0 { 645 return fileSeg{}, nil, errors.New("corrupt download") 646 } 647 // Try again. 648 os.Remove(partial) 649 return ns.syncSeg(ctx, seg) 650 } 651 // TODO: this is a quadratic amount of write I/O as the 16 MB 652 // segment grows. Switch to appending to the existing file, 653 // then perhaps encoding the desired file size into the 654 // filename suffix (instead of just *.growing.mutlog) so 655 // concurrent readers know where to stop. 656 tf, err := os.CreateTemp(ns.cacheDir, "tempseg") 657 if err != nil { 658 return fileSeg{}, nil, err 659 } 660 if _, err := tf.Write(newContents); err != nil { 661 return fileSeg{}, nil, err 662 } 663 if err := tf.Close(); err != nil { 664 return fileSeg{}, nil, err 665 } 666 finalName := partial 667 if !isFinalSeg { 668 finalName = frozen 669 } 670 if err := robustio.Rename(tf.Name(), finalName); err != nil { 671 return fileSeg{}, nil, err 672 } 673 if !ns.quiet { 674 log.Printf("wrote %v", finalName) 675 } 676 return fileSeg{seg: seg.Number, file: finalName, size: seg.Size, sha224: seg.SHA224}, newData, nil 677 } 678 679 type LogSegmentJSON struct { 680 Number int `json:"number"` 681 Size int64 `json:"size"` 682 SHA224 string `json:"sha224"` 683 URL string `json:"url"` 684 } 685 686 // fetchError records an error during a fetch operation over an unreliable network. 687 type fetchError struct { 688 Err error // Non-nil. 689 690 // PossiblyRetryable indicates whether Err is believed to be possibly caused by a 691 // non-terminal network error, such that the caller can expect it may not happen 692 // again if it simply tries the same fetch operation again after waiting a bit. 693 PossiblyRetryable bool 694 } 695 696 func (e fetchError) Error() string { return e.Err.Error() } 697 func (e fetchError) Unwrap() error { return e.Err }