Files
mesh-controller/internal/builder/clone_lookup.go
T
jschoubben acaf8830ec Try a forge clone again when its name lookup fails (issue 1773)
A build or check failed now and then on 'Could not resolve host:
novox.internal' between clones that resolved the same name, and its
whole delivery failed with it. A clone whose lookup failed reached
nothing, so it is cloned again with a growing pause for up to 60 s,
each retry said in the build's log as the resolver not answering;
past the bound it fails saying so. Any other git failure fails at
once, as before. Covers a module's own clone, an artifact's context
and a check's repositories.
2026-10-22 20:56:46 +02:00

115 lines
4.1 KiB
Go

package builder
import (
"context"
"fmt"
"os"
"strings"
"time"
)
// A clone from the forge waits out a resolver that did not answer, for a bounded time (novox/hq issue 1773).
//
// **Why.** A build or a check failed now and then on "Could not resolve host: novox.internal" while cloning, between
// clones that resolved the same name a second before and after, and the whole delivery failed with it. The lookup
// failing is the resolver's passing fault, not the repository's: nothing reached the forge, so cloning again cannot
// do anything twice. This is issue 457's shape (a build waits out a registry held still) applied to the name lookup.
//
// **Only the lookup.** Any other failure of git — authentication, a repository or a ref that is not there, a 404 —
// fails at once, as it always did: trying it again would only make a real failure slower.
//
// **Bounded, and loud.** Each retry is said in the build's log as the resolver's failure, and past lookupWait the
// build fails saying the resolver did not answer for that long.
var (
// lookupWait is how long a clone waits for a resolver that does not answer.
lookupWait = 60 * time.Second
// lookupFirstPause is the first pause between tries; each pause doubles, up to lookupMostPause.
lookupFirstPause = time.Second
)
// lookupMostPause is the longest pause between two tries.
const lookupMostPause = 10 * time.Second
// lookupFailures are what git (through curl) and Go's resolver say when a name did not resolve, in lower case.
var lookupFailures = []string{
"could not resolve host",
"temporary failure in name resolution",
}
// failedLookup is whether an error is a name lookup that failed: git's "Could not resolve host", the system's
// "Temporary failure in name resolution", or a lookup that timed out.
func failedLookup(err error) bool {
if err == nil {
return false
}
said := strings.ToLower(err.Error())
for _, f := range lookupFailures {
if strings.Contains(said, f) {
return true
}
}
return strings.Contains(said, "lookup ") && strings.Contains(said, "i/o timeout")
}
// cloneFromForge runs `git clone` of url into dest (git's arguments in args, run from dir), and while it fails on the
// name lookup, clones again with a growing pause until lookupWait has passed. What a failed try left at dest is
// removed before the next. Any other failure is returned at once.
func cloneFromForge(ctx context.Context, run Runner, dir, url, dest string, args []string,
say func(step, format string, args ...any)) error {
_, err := run(ctx, dir, "git", args...)
if !failedLookup(err) {
return err
}
started := time.Now()
pause := lookupFirstPause
for {
elapsed := time.Since(started)
left := lookupWait - elapsed
if left <= 0 {
say("clone", "the resolver did not answer for %s, cloning %s, for %s; the clone fails",
urlHost(url), url, elapsed.Round(time.Second))
return fmt.Errorf("the resolver did not answer a lookup of %s for %s, cloning %s: %w",
urlHost(url), elapsed.Round(time.Millisecond), url, err)
}
wait := min(pause, left)
say("clone", "the resolver did not answer for %s, cloning %s; trying again in %s, for up to %s "+
"(novox/hq issue 1773)", urlHost(url), url, wait, lookupWait)
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the resolver to answer for %s: %w (last: %v)",
urlHost(url), ctx.Err(), err)
case <-time.After(wait):
}
pause = min(pause*2, lookupMostPause)
if err := os.RemoveAll(dest); err != nil {
return err
}
if _, err = run(ctx, dir, "git", args...); !failedLookup(err) {
if err == nil {
say("clone", "the resolver answered for %s after %s; the clone worked", urlHost(url),
time.Since(started).Round(time.Millisecond))
}
return err
}
}
}
// urlHost is the host (and port) a URL names, or the URL itself when it names none.
func urlHost(url string) string {
rest := url
if _, after, ok := strings.Cut(url, "://"); ok {
rest = after
}
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
rest = rest[:i]
}
if i := strings.LastIndex(rest, "@"); i >= 0 {
rest = rest[i+1:]
}
if rest == "" {
return url
}
return rest
}