Artifact 4581417a13a544f52f405fa1d489c4e16f3e1e192338436beda32f076aeee724:
- File
internal/sync/retry.go
— part of check-in
[b2928e9629]
at
2026-07-27 20:03:00
on branch trunk
— fix(sync): retry a dropped exchange round instead of aborting the clone (#185) (#196)
A clone of a large repository is a conversation of dozens of rounds spanning minutes. A single transient disconnect anywhere in it aborted the whole operation with "http transport: Post ...: EOF" and produced no store at all: there was no retry, no reconnect, and minutes of transfer were discarded for one dropped packet. Reported failures landed at rounds 6, 11 and 18 across otherwise identical runs, which is the signature of independent random drops rather than a deterministic defect.
Both round loops now send a failed round again after a bounded exponential backoff — four attempts, 250 ms doubling to a 2 s cap — but only when the failure was the network dropping the exchange. The classification is an allow-list, not a deny-list: a reply that arrived whole and would not decode, a card the peer rejected, a 4xx, or an unrecognised error from a Transport this package does not know all still fail on their first occurrence. Retrying those would turn a reproducible defect into an intermittent one, which is worse than the failure retrying exists to prevent.
Telling the two apart cannot be done reliably after the fact — a truncated zlib body and a truncated wire read both surface as io.ErrUnexpectedEOF — so the transports tag their own wire stages via the optional TransportFault interface, in the same shape as FramedTransport. Non-2xx replies are now reported as such instead of being handed to the decoder, which also stops a peer's outage from being misreported as a framing defect.
The caller's context governs throughout: a cancellation or deadline aborts immediately, including during a backoff wait, so retrying cannot extend an operation past the bound its caller set (#166, #167, #183).
Resuming a partially-completed clone across process exit is deliberately not included: it needs the clone cursor persisted, a staleness policy for the server's snapshot bound, and a separate entry point, and within-session retry already preserves the expensive part. (user: [email protected], size: 8644) [annotate] [blame] [check-ins using]
package sync import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "syscall" "time" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) // Bounds on retrying a single exchange round. A clone of a large repository is // a conversation of many rounds spanning minutes, so one transient drop // anywhere in it used to discard the whole transfer (#185). Retrying is // bounded so a peer that is genuinely gone still fails promptly: the delays // below sum to under two seconds before the last attempt. const ( // MaxExchangeAttempts is how many times one round is sent before the // clone or sync gives up, the first attempt included. MaxExchangeAttempts = 4 // ExchangeRetryBaseDelay is the wait before the second attempt. It // doubles before each further attempt, capped at ExchangeRetryMaxDelay. ExchangeRetryBaseDelay = 250 * time.Millisecond // ExchangeRetryMaxDelay caps the backoff between attempts. ExchangeRetryMaxDelay = 2 * time.Second ) // TransportFault is implemented by an error that classifies itself for the // retry decision. A Transport knows which stage of the exchange failed — // whether bytes were still crossing the wire or a reply had already arrived // and was being interpreted — and that distinction cannot be recovered // reliably from the error's shape afterwards, since a truncated zlib body and // a truncated wire read both surface as [io.ErrUnexpectedEOF]. // // It is optional in the same way [FramedTransport] is: a Transport that does // not implement it has its errors classified by inspection instead, which is // deliberately conservative — anything not recognisably a network fault is // treated as fatal. type TransportFault interface { error // RetryableTransportFault reports whether sending the same request // again may succeed. It is true only when no reply was interpreted, so // a resend cannot mask a defect in one that was. RetryableTransportFault() bool } // transportFault tags an error with a retry verdict its raiser already knows. type transportFault struct { err error retryable bool } func (e *transportFault) Error() string { return e.err.Error() } func (e *transportFault) Unwrap() error { return e.err } func (e *transportFault) RetryableTransportFault() bool { return e.retryable } // RetryableFault tags an error raised while bytes were still crossing the // wire, so the round loop may resend the same request. Transports outside this // package use it for their read stage, where a short read means the reply was // cut off rather than malformed; a failure raised while interpreting a reply // that arrived whole must never be tagged. func RetryableFault(err error) error { if err == nil { panic("sync.RetryableFault: err must not be nil") } return &transportFault{err: err, retryable: true} } // StatusError reports a non-2xx HTTP reply from a sync peer. It is raised // instead of decoding the body, because an error page is not an xfer reply and // reporting it as a decode failure hides what actually happened. type StatusError struct { Code int // HTTP status code Status string // HTTP status line, e.g. "503 Service Unavailable" } func (e *StatusError) Error() string { return fmt.Sprintf("sync: peer replied %s", e.Status) } // RetryableTransportFault treats 5xx, 429 and 408 as worth another attempt: // each says the peer could not serve this request now, not that the request // was wrong. Every other status — 4xx above all — is the peer rejecting the // request itself, which repeating cannot fix and would only obscure. func (e *StatusError) RetryableTransportFault() bool { return e.Code >= 500 || e.Code == http.StatusTooManyRequests || e.Code == http.StatusRequestTimeout } // exchangeWithRetry runs one exchange round, resending it after a bounded // exponential backoff when the failure was a transport fault rather than a // protocol one. The caller's context governs throughout: a cancellation or // deadline aborts immediately, including during a backoff wait, so retrying // can never extend an operation past the bound its caller set. func exchangeWithRetry(ctx context.Context, t Transport, req *xfer.Message, clock simio.Clock, obs Observer) (*xfer.Message, error) { if t == nil { panic("sync.exchangeWithRetry: t must not be nil") } if req == nil { panic("sync.exchangeWithRetry: req must not be nil") } if clock == nil { clock = simio.RealClock{} } obs = resolveObserver(obs) delay := ExchangeRetryBaseDelay for attempt := 1; ; attempt++ { resp, err := t.Exchange(ctx, req) if err == nil { return resp, nil } // A cancelled or expired context is the caller's decision, never a // fault to retry — and it is reported as itself so callers keep // matching on context.Canceled and context.DeadlineExceeded. if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } if !retryableExchangeFault(err) { return nil, err } if attempt >= MaxExchangeAttempts { return nil, fmt.Errorf("after %d attempts: %w", MaxExchangeAttempts, err) } obs.Error(ctx, fmt.Errorf("sync: exchange attempt %d of %d failed, retrying in %v: %w", attempt, MaxExchangeAttempts, delay, err)) select { case <-ctx.Done(): return nil, ctx.Err() case <-clock.After(delay): } delay *= 2 if delay > ExchangeRetryMaxDelay { delay = ExchangeRetryMaxDelay } } } // retryableExchangeFault reports whether an exchange failure is worth another // attempt. It is deliberately an allow-list: only failures positively // identified as the network dropping the exchange are retried, and everything // else — a reply that would not decode, a card the peer rejected, an auth // failure, an unrecognised error from a Transport this package does not know — // fails on its first occurrence. Retrying those would turn a reproducible // defect into an intermittent one, which is worse than the failure retrying // exists to prevent. func retryableExchangeFault(err error) bool { if err == nil { return false } // A context that was cancelled or ran out is the caller's decision, not a // fault. A transport that imposes a deadline of its own also surfaces as // context.DeadlineExceeded and is treated the same way here — the two are // indistinguishable once wrapped, and refusing to retry is the // conservative reading. A network-level i/o timeout, which is distinct // from either, is still retried below. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } // A Transport that knows which stage failed has already said so. var fault TransportFault if errors.As(err, &fault) { return fault.RetryableTransportFault() } // An http.Client failure is always a *url.Error, and everything under it // was raised while the request or reply was still on the wire — no reply // has been interpreted at that point. This is where the reported failure // lands: "Post http://...: EOF", the peer going away mid-exchange. var urlErr *url.Error if errors.As(err, &urlErr) { return roundTripFaultIsTransient(urlErr.Err) } return networkFault(err) } // roundTripFaultIsTransient classifies a cause raised while an HTTP round trip // was still in flight. A truncated or closed connection counts here — unlike // in [networkFault], where the same sentinels could equally have come from a // decoder reading a short body, which is a protocol fault. func roundTripFaultIsTransient(err error) bool { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return true } return networkFault(err) } // networkFault reports whether err is recognisably the network failing, judged // only by types the net package raises for connection-level failures. func networkFault(err error) bool { // Name resolution: a temporary failure is worth retrying, but a name // that does not exist will not start existing. var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return dnsErr.IsTemporary || dnsErr.IsTimeout } var opErr *net.OpError if errors.As(err, &opErr) { return true } var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return true } // Reached when a connection error arrives without its *net.OpError // wrapper, e.g. from a Transport that unwrapped it. for _, errno := range []syscall.Errno{ syscall.ECONNRESET, syscall.ECONNABORTED, syscall.ECONNREFUSED, syscall.EPIPE, syscall.EHOSTUNREACH, syscall.ENETUNREACH, syscall.ENETDOWN, syscall.ETIMEDOUT, } { if errors.Is(err, errno) { return true } } return false }