From 51817c4cd969ed08886187ab3d4f618bf229e370 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Thu, 12 Mar 2026 18:38:40 +0000 Subject: [PATCH] knotmirror: add `knotBackoff` and reachability test git-cli doesn't support http connection timeout, so we cannot set short 30s connection timeout on git fetch. We don't want to put operation timeout that short because intial `git clone` can take pretty long. go-git does expose http client but only globally and is less efficient than cli. So as a hack, just fetch remote server to check if knot is available and is valid git remote server Signed-off-by: Seongmin Lee --- knotmirror/resyncer.go | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file(s) changed, 85 insertion(s)(+), 2 deletion(s)(-) diff --git a/knotmirror/resyncer.go b/knotmirror/resyncer.go --- a/knotmirror/resyncer.go +++ b/knotmirror/resyncer.go @@ -7,6 +7,8 @@ "fmt" "log/slog" "math/rand" + "net/http" + "net/url" "strings" "sync" "time" @@ -31,6 +33,9 @@ repoFetchTimeout time.Duration manualResyncTimeout time.Duration parallelism int + + knotBackoff map[string]time.Time + knotBackoffMu sync.RWMutex } func NewResyncer(l *slog.Logger, db *sql.DB, gitm GitMirrorManager, cfg *config.Config) *Resyncer { @@ -44,6 +49,8 @@ repoFetchTimeout: cfg.GitRepoFetchTimeout, manualResyncTimeout: 30 * time.Minute, parallelism: cfg.ResyncParallelism, + + knotBackoff: make(map[string]time.Time), } } @@ -203,8 +210,26 @@ return false, nil } - // TODO: check if Knot is on backoff list. If so, return (false, nil) - // TODO: detect rate limit error (http.StatusTooManyRequests) to put Knot in backoff list + r.knotBackoffMu.RLock() + backoffUntil, inBackoff := r.knotBackoff[repo.KnotDomain] + r.knotBackoffMu.RUnlock() + if inBackoff && time.Now().Before(backoffUntil) { + return false, nil + } + + // HACK: check knot reachability with short timeout before running actual fetch. + // This is crucial as git-cli doesn't support http connection timeout. + // `http.lowSpeedTime` is only applied _after_ the connection. + if err := r.checkKnotReachability(ctx, repo); err != nil { + if isRateLimitError(err) { + r.knotBackoffMu.Lock() + r.knotBackoff[repo.KnotDomain] = time.Now().Add(10 * time.Second) + r.knotBackoffMu.Unlock() + return false, nil + } + // TODO: suspend repo on 404. KnotStream updates will change the repo state back online + return false, fmt.Errorf("knot unreachable: %w", err) + } timeout := r.repoFetchTimeout if repo.RetryAfter == -1 { @@ -227,6 +252,64 @@ return false, fmt.Errorf("updating repo state to active %w", err) } return true, nil +} + +type knotStatusError struct { + StatusCode int +} + +func (ke *knotStatusError) Error() string { + return fmt.Sprintf("request failed with status code (HTTP %d)", ke.StatusCode) +} + +func isRateLimitError(err error) bool { + var knotErr *knotStatusError + if errors.As(err, &knotErr) { + return knotErr.StatusCode == http.StatusTooManyRequests + } + return false +} + +// checkKnotReachability checks if Knot is reachable and is valid git remote server +func (r *Resyncer) checkKnotReachability(ctx context.Context, repo *models.Repo) error { + repoUrl, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), true) + if err != nil { + return err + } + + repoUrl += "/info/refs?service=git-upload-pack" + + client := http.Client{ + Timeout: 30 * time.Second, + } + req, err := http.NewRequestWithContext(ctx, "GET", repoUrl, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "git/2.x") + req.Header.Set("Accept", "*/*") + + resp, err := client.Do(req) + if err != nil { + var uerr *url.Error + if errors.As(err, &uerr) { + return fmt.Errorf("request failed: %w", uerr.Unwrap()) + } + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &knotStatusError{resp.StatusCode} + } + + // check if target is git server + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/x-git-upload-pack-advertisement") { + return fmt.Errorf("unexpected content-type: %s", ct) + } + + return nil } func (r *Resyncer) handleResyncFailure(ctx context.Context, repoAt syntax.ATURI, err error) error { -- tangled.sh