From 82aaae846ea9c535d34465a90e902e5d9f773338 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 22:41:57 +0100 Subject: [PATCH] mesh-build-go opens a network failure of the fetch with the retake prefix (issue 1748) The builder takes a retakeable failure again by itself after a backoff. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../mesh-build-go/cmd/mesh-build-go/fetch.go | 3 +- .../mesh-build-go/cmd/mesh-build-go/pins.go | 3 +- .../mesh-build-go/cmd/mesh-build-go/retake.go | 39 +++++++++++++++++ .../cmd/mesh-build-go/retake_test.go | 42 +++++++++++++++++++ .../cmd/mesh-build-go/versions.go | 2 +- .../cmd/mesh-build-go/versions_test.go | 2 +- 6 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/retake.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/retake_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 8f66a1a9b..c7d1531f4 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -113,7 +113,8 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e } } }() - return m.fetchInto(ctx, req, id, siblings) + res, err = m.fetchInto(ctx, req, id, siblings) + return res, retakeable(err) // a network failure is a retakeable one (issue 1748) } func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id string, siblings []siblingArg) (any, error) { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins.go b/modules/mesh-build-go/cmd/mesh-build-go/pins.go index f51f52398..8865f7a9c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins.go @@ -17,6 +17,7 @@ import ( "regexp" "strings" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -140,7 +141,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head") if r.Status != 0 || r.Err != "" { // The forge did not answer: retaken, with its own cause, never held as a refusal until the module changes (issue 1748). - return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s failed (a download failure that may be retaken): %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf(delivery.Retake+"the pin of %s at commit %s: listing the pull requests of %s failed (a download failure, taken again by the builder): %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr)) } commit, ref := "", "" for _, l := range strings.Split(r.Stdout, "\n") { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/retake.go b/modules/mesh-build-go/cmd/mesh-build-go/retake.go new file mode 100644 index 000000000..a9a97c719 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/retake.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// networkWords are what a failed lookup, connection or transfer says in Go's, git's and the resolver's words: a failure of +// the network or of the forge that passes by itself (issue 1748), not of the source built. +var networkWords = []string{ + "i/o timeout", "no such host", "could not resolve host", "temporary failure in name resolution", "server misbehaving", + "connection refused", "connection reset", "tls handshake timeout", "network is unreachable", "unexpected eof", "http2: server sent goaway", +} + +// networkFailure says whether a failure of a download or a fetch is the network's: its words are one of networkWords and +// it is not go not knowing the revision (unknownRevision, a pin on no head), which waits for another fingerprint. +func networkFailure(failure string) bool { + if unknownRevision(failure) { + return false + } + low := strings.ToLower(failure) + for _, w := range networkWords { + if strings.Contains(low, w) { + return true + } + } + return false +} + +// retakeable opens a network failure of the fetch step with delivery.Retake, so the builder takes the key again after a +// backoff (delivery.Retakeable, issue 1748); any other error, one that opens with a prefix already included, is as it was. +func retakeable(err error) error { + if err == nil || strings.HasPrefix(err.Error(), delivery.Retake) || !networkFailure(err.Error()) { + return err + } + return fmt.Errorf("%s%w", delivery.Retake, err) +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/retake_test.go b/modules/mesh-build-go/cmd/mesh-build-go/retake_test.go new file mode 100644 index 000000000..e8f07e2cf --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/retake_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// 1748/AC2: a download or a fetch that fails on the network ends with an error that opens with the retake prefix; a +// compile error, a failing test and an unknown revision (a pin on no head) do not. +func TestANetworkFailureOfTheFetchOpensWithTheRetakePrefix(t *testing.T) { + for _, c := range []struct { + name, failure string + retake bool + }{ + {"a lookup that times out", "go mod download in .: git.novox.be/novox/mesh-sdk/go@v1: dial tcp: lookup git.novox.be on 10.10.0.3:53: read udp 10.10.0.3:42292->10.10.0.3:53: i/o timeout", true}, + {"a refused connection", "git ls-remote -q origin: dial tcp 10.10.0.1:443: connect: connection refused", true}, + {"a forge that cannot be resolved", "fatal: unable to access the forge: Could not resolve host: git.novox.be", true}, + {"an unknown revision", "invalid version: unknown revision abcdef123456", false}, + {"an unknown revision on a cut network is still one go knows", "unknown revision abcdef123456: i/o timeout", false}, + {"a compile error", "go build of tool: ./main.go:3:1: undefined: x", false}, + {"a failing test", "go test ./...: FAIL: TestX", false}, + } { + t.Run(c.name, func(t *testing.T) { + got := retakeable(errors.New(c.failure)) + if has := strings.HasPrefix(got.Error(), delivery.Retake); has != c.retake { + t.Fatalf("retakeable(%q) = %q", c.failure, got) + } + if c.retake && (!delivery.Retakeable(got.Error()) || !strings.Contains(got.Error(), c.failure)) { + t.Errorf("%q does not keep the cause or is not Retakeable", got) + } + }) + } + if retakeable(nil) != nil { + t.Error("retakeable(nil) is an error") + } + if once := retakeable(errors.New(delivery.Retake + "connection refused")); strings.Count(once.Error(), delivery.Retake) != 1 { + t.Errorf("the prefix was added twice: %q", once) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/versions.go b/modules/mesh-build-go/cmd/mesh-build-go/versions.go index 726319b66..c1b697b0d 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/versions.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/versions.go @@ -113,7 +113,7 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer pins.Unlock() // On no head, and go failed for another cause (the forge or the resolver did not answer): it fails with go's own // words, and not as a kind the builder holds until the module changes (issue 1748). - return fmt.Errorf("downloading the asked version %s of %s failed (a download failure that may be retaken): %s", tv.Asked, tv.Package, causeLine(failure)) + return fmt.Errorf(delivery.Retake+"downloading the asked version %s of %s failed (a download failure, taken again by the builder): %s", tv.Asked, tv.Package, causeLine(failure)) } if err == nil { err = m.replaceInModfile(dir, tv.Package, where) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go b/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go index 3d28eafda..37b6025b6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go @@ -92,7 +92,7 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) { st := states.waitEnd(t) if c.failsWith != "" { if st.State != delivery.StepFailed || !strings.Contains(st.Reason, c.failsWith) || strings.Contains(st.Reason, "no pull request head") || - !strings.Contains(st.Reason, "may be retaken") || delivery.FailedUntilChanged(st.Reason) { + !strings.HasPrefix(st.Reason, delivery.Retake) || delivery.FailedUntilChanged(st.Reason) { t.Errorf("the download failure ended %s: %q", st.State, st.Reason) } return