Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
22 kB · 557 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558package reposync
import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "syscall" "testing" "time"
"github.com/bluesky-social/indigo/xrpc" "github.com/ipfs/go-cid" "github.com/stretchr/testify/require")
// fastRetry is the policy the network tests use: same shape as production,// milliseconds instead of seconds.func fastRetry() RetryPolicy { return RetryPolicy{MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 20 * time.Millisecond}}
// throttled builds the failure a rate-limiting host sends, optionally with the// ratelimit-* headers indigo knows how to parse.func throttled(reset time.Time) failure { f := failure{ status: http.StatusTooManyRequests, body: `{"error":"RateLimitExceeded","message":"Rate Limit Exceeded"}`, header: map[string]string{"Content-Type": "application/json"}, } if !reset.IsZero() { f.header["ratelimit-limit"] = "3000" f.header["ratelimit-remaining"] = "0" f.header["ratelimit-policy"] = "3000;w=300" f.header["ratelimit-reset"] = strconv.FormatInt(reset.Unix(), 10) } return f}
// htmlThrottled is the shape that actually broke a production walk: a 429 whose// body is an HTML error page, so indigo cannot decode an XRPCError out of it and// only the status code survives.var htmlThrottled = failure{ status: http.StatusTooManyRequests, body: "<html><head><title>429 Too Many Requests</title></head><body>go away</body></html>", header: map[string]string{"Content-Type": "text/html"},}
func xrpcErr(status int, errStr, msg string) error { return fmt.Errorf("getBlocks: %w", &xrpc.Error{ StatusCode: status, Wrapped: &xrpc.XRPCError{ErrStr: errStr, Message: msg}, })}
func TestIsRetryable(t *testing.T) { for _, tc := range []struct { name string err error want bool }{ {"nil", nil, false}, {"429", xrpcErr(http.StatusTooManyRequests, "RateLimitExceeded", "slow down"), true}, {"429 with an undecodable body", fmt.Errorf("getBlocks: %w", &xrpc.Error{ StatusCode: http.StatusTooManyRequests, Wrapped: errors.New("failed to decode xrpc error message: invalid character '<'"), }), true}, {"500", xrpcErr(http.StatusInternalServerError, "InternalServerError", "oops"), true}, {"502", fmt.Errorf("x: %w", &xrpc.Error{StatusCode: http.StatusBadGateway}), true}, {"503", fmt.Errorf("x: %w", &xrpc.Error{StatusCode: http.StatusServiceUnavailable}), true}, {"504", fmt.Errorf("x: %w", &xrpc.Error{StatusCode: http.StatusGatewayTimeout}), true}, // Numerically 5xx, but a permanent answer: the backfill wants to hear // it at once so it can fall back to getRepo. {"501", fmt.Errorf("x: %w", &xrpc.Error{StatusCode: http.StatusNotImplemented}), false}, {"400 could not find cids", xrpcErr(http.StatusBadRequest, "InvalidRequest", "Could not find cids: bafy"), false}, {"401", fmt.Errorf("x: %w", &xrpc.Error{StatusCode: http.StatusUnauthorized}), false}, {"404", fmt.Errorf("x: %w", &xrpc.Error{StatusCode: http.StatusNotFound}), false}, {"connection reset", fmt.Errorf("request failed: %w", syscall.ECONNRESET), true}, {"connection refused", fmt.Errorf("request failed: %w", syscall.ECONNREFUSED), true}, {"truncated body", fmt.Errorf("reading response body: %w", io.ErrUnexpectedEOF), true}, {"timeout", fmt.Errorf("request failed: %w", timeoutError{}), true}, {"canceled", fmt.Errorf("request failed: %w", context.Canceled), false}, {"deadline exceeded", fmt.Errorf("request failed: %w", context.DeadlineExceeded), false}, {"missing block", fmt.Errorf("x: %w", ErrMissingBlock), false}, {"block mismatch", fmt.Errorf("x: %w", ErrBlockMismatch), false}, {"plain error", errors.New("nope"), false}, } { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, isRetryable(tc.err)) }) }}
type timeoutError struct{}
func (timeoutError) Error() string { return "i/o timeout" }func (timeoutError) Timeout() bool { return true }func (timeoutError) Temporary() bool { return true }
func TestRetryDelay(t *testing.T) { p := RetryPolicy{BaseDelay: time.Second, MaxDelay: 30 * time.Second} plain := errors.New("boom")
// Exponential, jittered down by at most 25%. for _, tc := range []struct{ attempt, wantSec int }{{1, 1}, {2, 2}, {3, 4}, {4, 8}, {5, 16}} { full := time.Duration(tc.wantSec) * time.Second for i := 0; i < 50; i++ { d, source := p.delay(tc.attempt, plain) require.GreaterOrEqual(t, d, time.Duration(float64(full)*0.75), "attempt %d", tc.attempt) require.LessOrEqual(t, d, full, "attempt %d", tc.attempt) require.Empty(t, source, "nothing told us to wait, so nothing is reported") } }
// Capped, and still jittered at the cap so a fleet does not resynchronize. var sawJitter bool for i := 0; i < 50; i++ { d, _ := p.delay(10, plain) require.GreaterOrEqual(t, d, 22500*time.Millisecond) require.LessOrEqual(t, d, 30*time.Second) if d < 29*time.Second { sawJitter = true } } require.True(t, sawJitter)
// The zero policy is the documented defaults. zero, _ := RetryPolicy{}.delay(1, plain) require.LessOrEqual(t, zero, DefaultRetryBaseDelay) require.GreaterOrEqual(t, zero, DefaultRetryBaseDelay*3/4)
t.Run("ratelimit reset is honored", func(t *testing.T) { // Further out than the backoff for attempt 1 (~1s): wait for the reset. err := ratelimited(time.Now().Add(3 * time.Second)) d, source := p.delay(1, err) require.Greater(t, d, 2500*time.Millisecond) require.LessOrEqual(t, d, 3500*time.Millisecond) require.Equal(t, "ratelimit-reset", source) })
t.Run("ratelimit reset is clamped to MaxDelay", func(t *testing.T) { // bsky rate limit windows are minutes long; we would rather make one // more doomed attempt than hold a per-PDS lock that long. err := ratelimited(time.Now().Add(10 * time.Minute)) d, source := p.delay(1, err) require.Equal(t, 30*time.Second, d) require.Equal(t, "ratelimit-reset", source) })
t.Run("a reset in the past does not shorten the backoff", func(t *testing.T) { err := ratelimited(time.Now().Add(-time.Minute)) d, source := p.delay(3, err) require.GreaterOrEqual(t, d, 3*time.Second) require.LessOrEqual(t, d, 4*time.Second) require.Empty(t, source) })
t.Run("a hint from the registry is honored", func(t *testing.T) { hints := NewBackoffHints() hints.Observe("https://pds.example/", http.StatusTooManyRequests, http.Header{"Retry-After": []string{"3"}}) hinted := RetryPolicy{BaseDelay: time.Second, MaxDelay: 30 * time.Second, Hints: hints, Host: "https://pds.example"}
d, source := hinted.delay(1, errors.New("boom")) require.Greater(t, d, 2500*time.Millisecond) require.LessOrEqual(t, d, 3500*time.Millisecond) require.Equal(t, "retry-after", source)
// A hint that is shorter than the ladder changes nothing: the ladder is // the floor, the hint only ever pushes a wait out. d, source = hinted.delay(5, errors.New("boom")) require.GreaterOrEqual(t, d, 12*time.Second) require.Empty(t, source)
// A different host is a different budget. other := hinted other.Host = "other.example" d, source = other.delay(1, errors.New("boom")) require.LessOrEqual(t, d, time.Second) require.Empty(t, source)
// And so is no host at all, which is what an un-plumbed policy looks // like. nohost := hinted nohost.Host = "" _, source = nohost.delay(1, errors.New("boom")) require.Empty(t, source) })
t.Run("the further-out of the two sources wins", func(t *testing.T) { hints := NewBackoffHints() hints.Observe("pds.example", http.StatusTooManyRequests, http.Header{"Retry-After": []string{"2"}}) hinted := RetryPolicy{BaseDelay: time.Second, MaxDelay: 30 * time.Second, Hints: hints, Host: "pds.example"}
// indigo parsed a reset further out than the header we captured. d, source := hinted.delay(1, ratelimited(time.Now().Add(6*time.Second))) require.Greater(t, d, 5*time.Second) require.Equal(t, "ratelimit-reset", source)
// And the other way around. d, source = hinted.delay(1, ratelimited(time.Now().Add(time.Millisecond))) require.Greater(t, d, 1500*time.Millisecond) require.Equal(t, "retry-after", source) })
t.Run("a hint is clamped to MaxDelay", func(t *testing.T) { hints := NewBackoffHints() hints.Observe("pds.example", http.StatusTooManyRequests, http.Header{"Retry-After": []string{"600"}}) hinted := RetryPolicy{BaseDelay: time.Second, MaxDelay: 30 * time.Second, Hints: hints, Host: "pds.example"} d, source := hinted.delay(1, errors.New("boom")) require.Equal(t, 30*time.Second, d) require.Equal(t, "retry-after", source) })
t.Run("a stale observation does not inflate a later wait", func(t *testing.T) { hints := NewBackoffHints() // An hour-long backoff, observed longer ago than hintMaxAge: the wait it // asked for has not elapsed, but it is no longer evidence about now. hints.observeAt("pds.example", http.StatusTooManyRequests, http.Header{"Retry-After": []string{"3600"}}, time.Now().Add(-hintMaxAge-time.Minute)) hinted := RetryPolicy{BaseDelay: time.Second, MaxDelay: 30 * time.Second, Hints: hints, Host: "pds.example"} d, source := hinted.delay(1, errors.New("boom")) require.LessOrEqual(t, d, time.Second) require.Empty(t, source) _, live := hints.Get("pds.example") require.False(t, live) })}
// nxdomain is a name that no longer resolves, wrapped the way it arrives: the// resolver's error inside net/http's dial error inside net/http's request// error.func nxdomain() error { return fmt.Errorf("getBlocks: %w", &url.Error{ Op: "Get", URL: "https://gone.example/xrpc/com.atproto.sync.getBlocks", Err: &net.OpError{Op: "dial", Net: "tcp", Err: &net.DNSError{ Err: "no such host", Name: "gone.example", IsNotFound: true, }}, })}
// TestRetryFastFailsDeadHosts: a host that is not there at all gets two// attempts, not five. This is what makes a sweep's straggler tail cheap --// switched-off PDSes were costing a full backoff ladder per repo to rediscover// something the first connection attempt already reported.func TestRetryFastFailsDeadHosts(t *testing.T) { policy := RetryPolicy{MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond} attempts := func(t *testing.T, err error) int { t.Helper() calls := 0 got := policy.do(context.Background(), "getBlocks", func() error { calls++ return err }) require.Error(t, got) return calls }
t.Run("connection refused", func(t *testing.T) { err := fmt.Errorf("request failed: %w", &url.Error{Op: "Get", URL: "https://pds.example/", Err: &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}}) require.True(t, isDeadHost(err)) // Still retryable: a PDS that is restarting refuses for a moment. require.True(t, isRetryable(err)) require.Equal(t, deadHostAttempts, attempts(t, err)) })
t.Run("no such host", func(t *testing.T) { err := nxdomain() require.True(t, isDeadHost(err)) // A name that does not resolve now will not resolve in a second, so // this never even reaches the two-attempt cap: it is not retryable at // all, and one attempt is what it costs. require.False(t, isRetryable(err)) require.Equal(t, 1, attempts(t, err)) })
t.Run("a timeout still gets the whole ladder", func(t *testing.T) { // The host is there and answering slowly, which is exactly what the // retries are for. err := fmt.Errorf("request failed: %w", timeoutError{}) require.False(t, isDeadHost(err)) require.Equal(t, 5, attempts(t, err)) })
t.Run("a DNS timeout is not a dead host", func(t *testing.T) { // The resolver is struggling, not answering "no": that is transient. err := fmt.Errorf("request failed: %w", &net.OpError{Op: "dial", Err: &net.DNSError{ Err: "i/o timeout", Name: "pds.example", IsTimeout: true, }}) require.False(t, isDeadHost(err)) require.Equal(t, 5, attempts(t, err)) })
t.Run("429 still gets the whole ladder", func(t *testing.T) { err := ratelimited(time.Time{}) require.False(t, isDeadHost(err)) require.Equal(t, 5, attempts(t, err)) })
t.Run("503 still gets the whole ladder", func(t *testing.T) { require.Equal(t, 5, attempts(t, xrpcErr(http.StatusServiceUnavailable, "", "restarting"))) })
t.Run("a policy that asks for less keeps it", func(t *testing.T) { single := RetryPolicy{MaxAttempts: 1, BaseDelay: time.Millisecond} calls := 0 err := single.do(context.Background(), "getBlocks", func() error { calls++ return fmt.Errorf("dialing: %w", syscall.ECONNREFUSED) }) require.Error(t, err) require.Equal(t, 1, calls) })}
// TestRetryDeadHostOffTheWire: the classification above is only worth anything// if a refused connection still looks like one after net/http and indigo have// each wrapped it, so this one dials a port that nothing is listening on.func TestRetryDeadHostOffTheWire(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) addr := ln.Addr().String() require.NoError(t, ln.Close())
sr := buildSignedRepo(t, testDID, exactnessPaths()) f := &XRPCBlockFetcher{ Client: &xrpc.Client{Host: "http://" + addr}, DID: testDID, Retry: RetryPolicy{MaxAttempts: 5, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond}, } _, err = f.GetBlocks(context.Background(), []cid.Cid{sr.root}) require.Error(t, err) require.True(t, isRetryable(err), "a refused connection is worth one retry: %v", err) require.True(t, isDeadHost(err), "but it must be recognisable as a dead host: %v", err) require.Contains(t, err.Error(), fmt.Sprintf("giving up after %d attempts", deadHostAttempts))}
func ratelimited(reset time.Time) error { return fmt.Errorf("getBlocks: %w", &xrpc.Error{ StatusCode: http.StatusTooManyRequests, Wrapped: &xrpc.XRPCError{ErrStr: "RateLimitExceeded"}, Ratelimit: &xrpc.RatelimitInfo{Limit: 3000, Reset: reset}, })}
// TestErrForLog: the retry warning is the one line an operator sees when a host// is throttling a walk, and for the commonest case -- a 429 with an HTML body --// indigo's JSON decoder failure was burying the status code in parser trivia.func TestErrForLog(t *testing.T) { // The genuine article, straight off the wire. sr := buildSignedRepo(t, testDID, exactnessPaths()) host := newFakeHost(sr) host.blocksFailures = []failure{htmlThrottled} client := host.start(t) f := &XRPCBlockFetcher{Client: client, DID: testDID, Retry: RetryPolicy{MaxAttempts: 1}} _, err := f.GetBlocks(context.Background(), []cid.Cid{sr.root}) require.Error(t, err) require.Contains(t, err.Error(), "failed to decode xrpc error message", "fixture no longer produces the shape under test") require.Equal(t, "HTTP 429 (undecodable error body)", errForLog(err))
// A host that answered properly keeps its whole message. proper := xrpcErr(http.StatusTooManyRequests, "RateLimitExceeded", "Rate Limit Exceeded") require.Equal(t, proper, errForLog(proper)) // So does anything that never reached a host. plain := fmt.Errorf("dialing: %w", syscall.ECONNREFUSED) require.Equal(t, plain, errForLog(plain)) // And a 502 from a load balancer gets the same treatment as the 429. gateway := fmt.Errorf("getBlocks: %w", &xrpc.Error{ StatusCode: http.StatusBadGateway, Wrapped: fmt.Errorf("failed to decode xrpc error message: %w", errors.New("invalid character '<'")), }) require.Equal(t, "HTTP 502 (undecodable error body)", errForLog(gateway))}
// TestXRPCBlockFetcherRetries drives the retry loop through the real getBlocks// path against an HTTP host that fails on a script.func TestXRPCBlockFetcherRetries(t *testing.T) { ctx := context.Background() sr := buildSignedRepo(t, testDID, exactnessPaths())
newFetcher := func(t *testing.T, script ...failure) (*XRPCBlockFetcher, *fakeHost) { host := newFakeHost(sr) host.blocksFailures = script client := host.start(t) return &XRPCBlockFetcher{Client: client, DID: testDID, Retry: fastRetry()}, host }
t.Run("429 then success", func(t *testing.T) { f, host := newFetcher(t, throttled(time.Time{}), throttled(time.Time{})) blocks, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.NoError(t, err) require.Contains(t, blocks, sr.root) require.Equal(t, 3, host.requests, "two throttles then the real answer") })
t.Run("429 with an HTML body then success", func(t *testing.T) { // The production shape: indigo cannot decode the body, so the error is // "failed to decode xrpc error message: invalid character '<'" and only // the status code is left to classify on. f, host := newFetcher(t, htmlThrottled) _, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.NoError(t, err) require.Equal(t, 2, host.requests) })
t.Run("503 then success", func(t *testing.T) { f, host := newFetcher(t, failure{status: http.StatusServiceUnavailable, body: "upstream restarting"}) _, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.NoError(t, err) require.Equal(t, 2, host.requests) })
t.Run("a ratelimit reset in the far future is capped, not slept on", func(t *testing.T) { f, host := newFetcher(t, throttled(time.Now().Add(5*time.Minute))) start := time.Now() _, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.NoError(t, err) require.Equal(t, 2, host.requests) require.Less(t, time.Since(start), time.Second, "MaxDelay must bound the ratelimit wait") })
t.Run("400 fails fast", func(t *testing.T) { // What a TS PDS says when the walk raced a live repo and the blocks of // the pinned commit have been garbage collected. Retrying the same CIDs // can never help. f, host := newFetcher(t, failure{ status: http.StatusBadRequest, body: `{"error":"InvalidRequest","message":"Could not find cids: bafyreib2"}`, header: map[string]string{"Content-Type": "application/json"}, }) _, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.Error(t, err) require.Equal(t, 1, host.requests, "no retries") var xe *xrpc.Error require.ErrorAs(t, err, &xe) require.Equal(t, http.StatusBadRequest, xe.StatusCode) require.NotContains(t, err.Error(), "giving up", "a fail-fast error is passed through unchanged") })
t.Run("retries exhausted", func(t *testing.T) { f, host := newFetcher(t, throttled(time.Time{}), throttled(time.Time{}), throttled(time.Time{}), throttled(time.Time{}), throttled(time.Time{}), throttled(time.Time{})) _, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.Error(t, err) require.Equal(t, 5, host.requests, "MaxAttempts is a total, not an extra") require.Contains(t, err.Error(), "giving up after 5 attempts") var xe *xrpc.Error require.ErrorAs(t, err, &xe, "the last error is still inspectable") require.Equal(t, http.StatusTooManyRequests, xe.StatusCode) })
t.Run("context cancelled mid backoff", func(t *testing.T) { host := newFakeHost(sr) host.blocksFailures = []failure{throttled(time.Time{})} client := host.start(t) // A backoff long enough that returning promptly can only mean the // sleep was context aware. f := &XRPCBlockFetcher{Client: client, DID: testDID, Retry: RetryPolicy{MaxAttempts: 5, BaseDelay: 30 * time.Second, MaxDelay: time.Minute}}
ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(20 * time.Millisecond) cancel() }() start := time.Now() _, err := f.GetBlocks(ctx, []cid.Cid{sr.root}) require.Error(t, err) require.Less(t, time.Since(start), 5*time.Second) require.ErrorIs(t, err, context.Canceled) var xe *xrpc.Error require.ErrorAs(t, err, &xe, "the failure that triggered the backoff is kept too") require.Equal(t, http.StatusTooManyRequests, xe.StatusCode) require.Equal(t, 1, host.requests) })
t.Run("retrying does not break chunking", func(t *testing.T) { // A retry inside one chunk must not disturb the chunk loop: five CIDs // at ChunkSize 2 is three chunks, and the failure only costs one extra // call. host := newFakeHost(sr) host.blocksFailures = []failure{throttled(time.Time{})} client := host.start(t) f := &XRPCBlockFetcher{Client: client, DID: testDID, ChunkSize: 2, Retry: fastRetry()} want := []cid.Cid{sr.root, sr.commitCID} for c := range sr.blocks { if len(want) >= 5 { break } if c != sr.root && c != sr.commitCID { want = append(want, c) } } require.Len(t, want, 5) blocks, err := f.GetBlocks(ctx, want) require.NoError(t, err) require.Len(t, blocks, len(want)) require.Equal(t, 4, host.requests, "three chunks plus the one retry") })}
// TestFetchVerifiedHeadRetries: the head fetch is one getLatestCommit call, and// it is the first thing every backfill does, so it gets the same treatment.func TestFetchVerifiedHeadRetries(t *testing.T) { ctx := context.Background() sr := buildSignedRepo(t, testDID, exactnessPaths()) pub, err := sr.priv.PublicKey() require.NoError(t, err)
t.Run("throttled then success", func(t *testing.T) { host := newFakeHost(sr) host.latestFailures = []failure{throttled(time.Time{}), htmlThrottled, {status: http.StatusServiceUnavailable, body: "restarting"}} client := host.start(t) f := &XRPCBlockFetcher{Client: client, DID: testDID, Retry: fastRetry()} head, err := FetchVerifiedHead(ctx, client, f, sr.directory(t, testDID, pub), testDID, fastRetry()) require.NoError(t, err) require.Equal(t, sr.commitCID, head.CID) require.Equal(t, 4, host.latestRequests) })
t.Run("RepoNotFound fails fast", func(t *testing.T) { host := newFakeHost(sr) host.latestFailures = []failure{{ status: http.StatusBadRequest, body: `{"error":"RepoNotFound","message":"could not find repo"}`, header: map[string]string{"Content-Type": "application/json"}, }} client := host.start(t) f := &XRPCBlockFetcher{Client: client, DID: testDID, Retry: fastRetry()} _, err := FetchVerifiedHead(ctx, client, f, sr.directory(t, testDID, pub), testDID, fastRetry()) require.Error(t, err) require.Equal(t, 1, host.latestRequests) })
t.Run("at most one policy", func(t *testing.T) { host := newFakeHost(sr) client := host.start(t) f := &XRPCBlockFetcher{Client: client, DID: testDID} _, err := FetchVerifiedHead(ctx, client, f, sr.directory(t, testDID, pub), testDID, fastRetry(), fastRetry()) require.Error(t, err) })}