Artifact 696aa82a8250f98056ef35b6c0c5c65ffbe28f46b92b7e88a1b8b66adfc268c8:
- File
transport.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: 5082) [annotate] [blame] [check-ins using]
package libfossil import ( "bytes" "context" "fmt" "io" "net/http" internalsync "github.com/danmestas/go-libfossil/internal/sync" ) // Transport delivers sync payloads between peers. // Implementations handle the network layer (HTTP, NATS, etc.). // Payloads are opaque zlib-compressed xfer card streams. type Transport interface { RoundTrip(ctx context.Context, payload []byte) ([]byte, error) } // FramedTransport is an optional interface a Transport may also implement when // it can report the framing signal — such as an HTTP Content-Type header — that // accompanied a reply. Sync uses that signal to select the reply's §4 framing // instead of assuming the §4.1 compressed container, which matters when the // peer is a different Fossil-protocol implementation that legitimately replies // with the uncompressed plain-card framing (e.g. a real Fossil server's clone // reply). // // It is optional in the same way http.Flusher is optional on an // http.ResponseWriter: a Transport whose framing is fixed by convention (both // ends always emit the compressed container) need not implement it, and sync // falls back to assuming the compressed framing for such a Transport. Callers // type-assert a Transport to FramedTransport and use RoundTripFramed when it is // available. Reporting the signal as a return value keeps it per-call, with no // shared mutable state to make a reused Transport unsafe across concurrent // calls. type FramedTransport interface { Transport // RoundTripFramed behaves like RoundTrip but also returns the framing // signal (e.g. the reply's Content-Type) the wire carried for this reply, // or "" when the transport observed none. RoundTripFramed(ctx context.Context, payload []byte) (resp []byte, contentType string, err error) } // NewHTTPTransport creates a Transport that speaks Fossil's HTTP /xfer protocol. func NewHTTPTransport(url string, opts ...HTTPOption) Transport { t := &httpTransport{url: url, client: http.DefaultClient} for _, o := range opts { o(t) } return t } // HTTPOption configures an HTTP transport. type HTTPOption func(*httpTransport) // WithHTTPClient sets a custom http.Client for the transport. func WithHTTPClient(c *http.Client) HTTPOption { return func(t *httpTransport) { t.client = c } } type httpTransport struct { url string client *http.Client } func (t *httpTransport) RoundTrip(ctx context.Context, payload []byte) ([]byte, error) { body, _, err := t.roundTrip(ctx, payload) return body, err } // RoundTripFramed implements FramedTransport: it returns the reply body along // with the reply's Content-Type, which selects the §4 framing for decoding. A // peer that replies uncompressed (e.g. a real Fossil clone reply) is decoded // correctly instead of being force-read as the compressed container. func (t *httpTransport) RoundTripFramed(ctx context.Context, payload []byte) ([]byte, string, error) { return t.roundTrip(ctx, payload) } func (t *httpTransport) roundTrip(ctx context.Context, payload []byte) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, "POST", t.url, bytes.NewReader(payload)) if err != nil { return nil, "", fmt.Errorf("libfossil: http transport: %w", err) } req.Header.Set("Content-Type", "application/x-fossil") resp, err := t.client.Do(req) if err != nil { return nil, "", fmt.Errorf("libfossil: http transport: %w", err) } defer resp.Body.Close() // An error page is not an xfer reply. Reporting the status directly keeps // a peer's outage from surfacing as a framing defect, and lets the sync // round loop tell a 5xx worth retrying from a 4xx that never will be. if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, "", &internalsync.StatusError{Code: resp.StatusCode, Status: resp.Status} } body, err := io.ReadAll(resp.Body) if err != nil { // The reply was cut short on the wire; nothing has been decoded yet, // so the round loop may safely send this request again. Tagging it // here is what distinguishes a severed connection from a body that // arrived whole and would not decode — both surface as // io.ErrUnexpectedEOF, and only the former is worth retrying. return nil, "", internalsync.RetryableFault( fmt.Errorf("libfossil: http transport read: %w", err)) } return body, resp.Header.Get("Content-Type"), nil } // MockTransport is a test double that delegates to a handler function. type MockTransport struct { Handler func(req []byte) []byte } // RoundTrip calls the Handler function if set, otherwise returns empty bytes. func (t *MockTransport) RoundTrip(_ context.Context, payload []byte) ([]byte, error) { if t.Handler == nil { return []byte{}, nil } return t.Handler(payload), nil } // TransportFunc adapts a plain function to the Transport interface. // This is the Transport equivalent of http.HandlerFunc. type TransportFunc func(ctx context.Context, payload []byte) ([]byte, error) // RoundTrip calls the function. func (f TransportFunc) RoundTrip(ctx context.Context, payload []byte) ([]byte, error) { return f(ctx, payload) }