From aba5c75b7beb1420526b527007026e928efebaf3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 2 Jul 2026 21:20:39 +0200 Subject: [PATCH 1/2] polygon/heimdall: pin HttpClient fetcher V1/V2 behavior in tests Cover all nine versioned fetchers in both Heimdall API versions via the mocked HTTP request handler: URL paths and pagination queries, response transforms, count string parsing, fetch error wrapping, invalid milestone index mapping to ErrNotInMilestoneList, and recoverable-error retry behavior. Safety net for collapsing the repeated V1/V2 fetch scaffold. --- polygon/heimdall/client_http_test.go | 429 +++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) diff --git a/polygon/heimdall/client_http_test.go b/polygon/heimdall/client_http_test.go index 300561de11d..a4b7819aee9 100644 --- a/polygon/heimdall/client_http_test.go +++ b/polygon/heimdall/client_http_test.go @@ -18,14 +18,19 @@ package heimdall import ( "context" + "encoding/base64" + "fmt" "io" + "math/big" "net/http" + "strings" "testing" "time" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/testlog" "github.com/erigontech/erigon/polygon/heimdall/poshttp" @@ -69,3 +74,427 @@ func TestHeimdallClientFetchesTerminateUponTooManyErrors(t *testing.T) { require.Nil(t, spanRes) require.Error(t, err) } + +type mockHeimdallResponse struct { + status int + body string +} + +func okBody(body string) mockHeimdallResponse { + return mockHeimdallResponse{status: http.StatusOK, body: body} +} + +func newTestHttpClient( + t *testing.T, + version poshttp.HeimdallVersion, + responses map[string]mockHeimdallResponse, +) (*HttpClient, map[string]int) { + requestHandler := poshttp.NewMockhttpRequestHandler(gomock.NewController(t)) + requests := map[string]int{} + requestHandler.EXPECT(). + Do(gomock.Any()). + DoAndReturn(func(req *http.Request) (*http.Response, error) { + key := req.URL.Path + if req.URL.RawQuery != "" { + key += "?" + req.URL.RawQuery + } + requests[key]++ + response, ok := responses[key] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("unexpected request: " + key)), + }, nil + } + return &http.Response{ + StatusCode: response.status, + Body: io.NopCloser(strings.NewReader(response.body)), + }, nil + }). + AnyTimes() + + opts := []poshttp.ClientOption{ + poshttp.WithHttpRequestHandler(requestHandler), + poshttp.WithHttpRetryBackOff(time.Millisecond), + poshttp.WithHttpMaxRetries(2), + } + if version == poshttp.HeimdallV2 { + responses["/chainmanager/params"] = okBody(`{"params":{"chain_params":{"pol_token_address":"0x0000000000000000000000000000000000001010"}}}`) + opts = append(opts, poshttp.WithApiVersioner(context.Background())) + } + + return NewHttpClient("https://dummyheimdall.com", testlog.Logger(t, log.LvlDebug), opts...), requests +} + +func TestHttpClientFetchersRouteByVersion(t *testing.T) { + proposer := common.HexToAddress("0x0000000000000000000000000000000000000001") + rootHash := common.HexToHash("0x1234000000000000000000000000000000000000000000000000000000005678") + rootHashBase64 := base64.StdEncoding.EncodeToString(rootHash[:]) + + spanV1JSON := `{"span_id":1534,"start_block":100,"end_block":200,"bor_chain_id":"137"}` + wantSpanV1 := &Span{Id: 1534, StartBlock: 100, EndBlock: 200, ChainID: "137"} + + validatorJSON := fmt.Sprintf(`{"val_id":"1","signer":"%s","voting_power":"10","proposer_priority":"0"}`, proposer) + wantValidator := Validator{ID: 1, Address: proposer, VotingPower: 10} + spanV2JSON := fmt.Sprintf( + `{"id":"1534","start_block":"100","end_block":"200","validator_set":{"validators":[%s],"proposer":%s},"selected_producers":[%s],"bor_chain_id":"137"}`, + validatorJSON, validatorJSON, validatorJSON, + ) + wantSpanV2 := &Span{ + Id: 1534, + StartBlock: 100, + EndBlock: 200, + ValidatorSet: ValidatorSet{ + Validators: []*Validator{&wantValidator}, + Proposer: &wantValidator, + }, + SelectedProducers: []Validator{wantValidator}, + ChainID: "137", + } + + wantWaypointFields := WaypointFields{ + Proposer: proposer, + StartBlock: big.NewInt(100), + EndBlock: big.NewInt(200), + RootHash: rootHash, + ChainID: "137", + Timestamp: 1712, + } + checkpointV1JSON := fmt.Sprintf( + `{"id":5,"proposer":"%s","start_block":100,"end_block":200,"root_hash":"%s","bor_chain_id":"137","timestamp":1712}`, + proposer, rootHash, + ) + checkpointV2JSON := fmt.Sprintf( + `{"id":"5","proposer":"%s","start_block":"100","end_block":"200","root_hash":"%s","bor_chain_id":"137","timestamp":"1712"}`, + proposer, rootHashBase64, + ) + wantCheckpoint := &Checkpoint{Id: 5, Fields: wantWaypointFields} + + milestoneV1JSON := fmt.Sprintf( + `{"milestone_id":"m-100","proposer":"%s","start_block":100,"end_block":200,"hash":"%s","bor_chain_id":"137","timestamp":1712}`, + proposer, rootHash, + ) + milestoneV2JSON := fmt.Sprintf( + `{"milestone_id":"m-100","proposer":"%s","start_block":"100","end_block":"200","hash":"%s","bor_chain_id":"137","timestamp":"1712"}`, + proposer, rootHashBase64, + ) + wantMilestone := &Milestone{Id: 100, MilestoneId: "m-100", Fields: wantWaypointFields} + + statusJSON := `{"latest_block_hash":"0xabc","latest_app_hash":"0xdef","latest_block_time":"2024-01-01T00:00:00Z","catching_up":true}` + wantStatus := &Status{ + LatestBlockHash: "0xabc", + LatestAppHash: "0xdef", + LatestBlockTime: "2024-01-01T00:00:00Z", + CatchingUp: true, + } + + for _, tc := range []struct { + name string + version poshttp.HeimdallVersion + responses map[string]mockHeimdallResponse + fetch func(ctx context.Context, client *HttpClient) (any, error) + want any + }{ + { + name: "FetchLatestSpan v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/bor/latest-span": okBody(`{"height":"1","result":` + spanV1JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchLatestSpan(ctx) + }, + want: wantSpanV1, + }, + { + name: "FetchLatestSpan v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/bor/spans/latest": okBody(`{"span":` + spanV2JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchLatestSpan(ctx) + }, + want: wantSpanV2, + }, + { + name: "FetchSpan v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/bor/span/1534": okBody(`{"height":"1","result":` + spanV1JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchSpan(ctx, 1534) + }, + want: wantSpanV1, + }, + { + name: "FetchSpan v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/bor/spans/1534": okBody(`{"span":` + spanV2JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchSpan(ctx, 1534) + }, + want: wantSpanV2, + }, + { + name: "FetchSpans v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/bor/span/list?page=2&limit=10": okBody(`{"height":"1","result":[` + spanV1JSON + `]}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchSpans(ctx, 2, 10) + }, + want: []*Span{wantSpanV1}, + }, + { + name: "FetchSpans v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/bor/spans/list?pagination.offset=10&pagination.limit=10": okBody(`{"span_list":[` + spanV2JSON + `]}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchSpans(ctx, 2, 10) + }, + want: []*Span{wantSpanV2}, + }, + { + name: "FetchCheckpoint v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/checkpoints/5": okBody(`{"height":"1","result":` + checkpointV1JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchCheckpoint(ctx, 5) + }, + want: wantCheckpoint, + }, + { + name: "FetchCheckpoint v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/checkpoints/5": okBody(`{"checkpoint":` + checkpointV2JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchCheckpoint(ctx, 5) + }, + want: wantCheckpoint, + }, + { + name: "FetchCheckpoints v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/checkpoints/list?page=2&limit=10": okBody(`{"height":"1","result":[` + checkpointV1JSON + `]}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchCheckpoints(ctx, 2, 10) + }, + want: []*Checkpoint{wantCheckpoint}, + }, + { + name: "FetchCheckpoints v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/checkpoints/list?pagination.offset=10&pagination.limit=10": okBody(`{"checkpoint_list":[` + checkpointV2JSON + `]}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchCheckpoints(ctx, 2, 10) + }, + want: []*Checkpoint{wantCheckpoint}, + }, + { + name: "FetchMilestone v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/milestone/100": okBody(`{"height":"1","result":` + milestoneV1JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestone(ctx, 100) + }, + want: wantMilestone, + }, + { + name: "FetchMilestone v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/milestones/100": okBody(`{"milestone":` + milestoneV2JSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestone(ctx, 100) + }, + want: wantMilestone, + }, + { + name: "FetchStatus v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/status": okBody(`{"height":"1","result":` + statusJSON + `}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchStatus(ctx) + }, + want: wantStatus, + }, + { + name: "FetchStatus v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/status": okBody(statusJSON), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchStatus(ctx) + }, + want: wantStatus, + }, + { + name: "FetchCheckpointCount v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/checkpoints/count": okBody(`{"height":"1","result":{"result":420}}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchCheckpointCount(ctx) + }, + want: int64(420), + }, + { + name: "FetchCheckpointCount v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/checkpoints/count": okBody(`{"ack_count":"420"}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchCheckpointCount(ctx) + }, + want: int64(420), + }, + { + name: "FetchMilestoneCount v1", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/milestone/count": okBody(`{"height":"1","result":{"count":420}}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestoneCount(ctx) + }, + want: int64(420), + }, + { + name: "FetchMilestoneCount v2", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/milestones/count": okBody(`{"count":"420"}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestoneCount(ctx) + }, + want: int64(420), + }, + } { + t.Run(tc.name, func(t *testing.T) { + client, _ := newTestHttpClient(t, tc.version, tc.responses) + got, err := tc.fetch(context.Background(), client) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestHttpClientFetchErrorHandling(t *testing.T) { + for _, tc := range []struct { + name string + version poshttp.HeimdallVersion + responses map[string]mockHeimdallResponse + fetch func(ctx context.Context, client *HttpClient) (any, error) + assertErr func(t *testing.T, err error) + wantRequests map[string]int + }{ + { + name: "FetchSpan v1 wraps fetch error with spanID", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{}, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchSpan(ctx, 1534) + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, poshttp.ErrNotSuccessfulResponse) + require.ErrorContains(t, err, "spanID=1534") + }, + wantRequests: map[string]int{"/bor/span/1534": 2}, + }, + { + name: "FetchSpan v2 wraps fetch error with spanID", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{}, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchSpan(ctx, 1534) + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, poshttp.ErrNotSuccessfulResponse) + require.ErrorContains(t, err, "spanID=1534") + }, + wantRequests: map[string]int{"/bor/spans/1534": 2}, + }, + { + name: "FetchMilestone v1 pruned milestone is not retried", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/milestone/5": {status: http.StatusInternalServerError, body: "Invalid milestone index"}, + "/milestone/count": okBody(`{"height":"1","result":{"count":200}}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestone(ctx, 5) + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, ErrNotInMilestoneList) + require.ErrorContains(t, err, "number 5") + }, + wantRequests: map[string]int{"/milestone/5": 1}, + }, + { + name: "FetchMilestone v2 pruned milestone is not retried", + version: poshttp.HeimdallV2, + responses: map[string]mockHeimdallResponse{ + "/milestones/5": {status: http.StatusInternalServerError, body: "Invalid milestone index"}, + "/milestones/count": okBody(`{"count":"200"}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestone(ctx, 5) + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, ErrNotInMilestoneList) + require.ErrorContains(t, err, "number 5") + }, + wantRequests: map[string]int{"/milestones/5": 1}, + }, + { + name: "FetchMilestone v1 non-pruned milestone is retried", + version: poshttp.HeimdallV1, + responses: map[string]mockHeimdallResponse{ + "/milestone/150": {status: http.StatusInternalServerError, body: "Invalid milestone index"}, + "/milestone/count": okBody(`{"height":"1","result":{"count":200}}`), + }, + fetch: func(ctx context.Context, client *HttpClient) (any, error) { + return client.FetchMilestone(ctx, 150) + }, + assertErr: func(t *testing.T, err error) { + require.ErrorIs(t, err, ErrNotInMilestoneList) + require.ErrorContains(t, err, "number 150") + }, + wantRequests: map[string]int{"/milestone/150": 2}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + client, requests := newTestHttpClient(t, tc.version, tc.responses) + result, err := tc.fetch(context.Background(), client) + require.Nil(t, result) + tc.assertErr(t, err) + for key, count := range tc.wantRequests { + require.Equal(t, count, requests[key], "unexpected request count for %s", key) + } + }) + } +} From e2a90244ed7fc3cf887295372a3383f59a892206 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 2 Jul 2026 21:27:00 +0200 Subject: [PATCH 2/2] polygon/heimdall: collapse V1/V2 fetch scaffold into fetchVersioned generic Every versioned fetcher repeated the same scaffold: switch on c.Version(), fetch the V1 or V2 response type with retry, then apply the per-version transform. Introduce fetchVersioned/fetchVersionedEx generics that own that flow, and convert all nine fetchers to them. URL construction stays at call sites since paths and pagination queries differ per endpoint and version. fetchVersionedOpts carries the two per-site knobs: isRecoverableError (FetchMilestone's pruned-milestone retry decision) and wrapFetchErr, which wraps only fetch errors so FetchSpan's spanID context and FetchMilestone's ErrNotInMilestoneList mapping keep applying exactly where they did before, leaving transform errors untouched. V2 string counts keep strconv.Atoi semantics via parseCount. Both version URLs are now built eagerly; MakeURL can only fail on parsing the base URL string, which is version-independent, so the error surface is unchanged. A follow-up idea to rewrite poshttp.FetchWithRetryEx on top of cenkalti/backoff was considered and dropped: bridging client.closeCh into a context plus reproducing the select's priority between ctx.Err(), ErrShutdownDetected and the last attempt error (which can itself wrap context.Canceled) would need a per-call goroutine and post-hoc error disambiguation, and backoff's Notify hook never fires on the final attempt, changing the attempt-log cadence. --- polygon/heimdall/client_http.go | 291 ++++++++++++++------------------ 1 file changed, 125 insertions(+), 166 deletions(-) diff --git a/polygon/heimdall/client_http.go b/polygon/heimdall/client_http.go index 9a2e9ba42bd..fb3010e36b0 100644 --- a/polygon/heimdall/client_http.go +++ b/polygon/heimdall/client_http.go @@ -78,96 +78,114 @@ const ( fetchSpanListPathV2 = "bor/spans/list" ) -func (c *HttpClient) FetchLatestSpan(ctx context.Context) (*Span, error) { - ctx = poshttp.WithRequestType(ctx, poshttp.SpanRequest) +type fetchVersionedOpts struct { + isRecoverableError func(error) bool + wrapFetchErr func(error) error +} + +func (opts fetchVersionedOpts) wrapErr(err error) error { + if opts.wrapFetchErr == nil { + return err + } + return opts.wrapFetchErr(err) +} +func fetchVersioned[TV1, TV2, T any]( + ctx context.Context, + c *HttpClient, + urlV1, urlV2 *url.URL, + fromV1 func(*TV1) (T, error), + fromV2 func(*TV2) (T, error), +) (T, error) { + return fetchVersionedEx(ctx, c, urlV1, urlV2, fromV1, fromV2, fetchVersionedOpts{}) +} + +func fetchVersionedEx[TV1, TV2, T any]( + ctx context.Context, + c *HttpClient, + urlV1, urlV2 *url.URL, + fromV1 func(*TV1) (T, error), + fromV2 func(*TV2) (T, error), + opts fetchVersionedOpts, +) (T, error) { if c.Version() == poshttp.HeimdallV2 { - url, err := poshttp.MakeURL(c.UrlString, fetchSpanLatestV2, "") - if err != nil { - return nil, err - } - response, err := poshttp.FetchWithRetry[SpanResponseV2](ctx, c.Client, url, c.Logger) + response, err := poshttp.FetchWithRetryEx[TV2](ctx, c.Client, urlV2, opts.isRecoverableError, c.Logger) if err != nil { - return nil, err + var zero T + return zero, opts.wrapErr(err) } - return response.ToSpan() + return fromV2(response) } - url, err := poshttp.MakeURL(c.UrlString, fetchSpanLatestV1, "") + response, err := poshttp.FetchWithRetryEx[TV1](ctx, c.Client, urlV1, opts.isRecoverableError, c.Logger) if err != nil { - return nil, err + var zero T + return zero, opts.wrapErr(err) } - response, err := poshttp.FetchWithRetry[SpanResponseV1](ctx, c.Client, url, c.Logger) + return fromV1(response) +} + +func (c *HttpClient) FetchLatestSpan(ctx context.Context) (*Span, error) { + ctx = poshttp.WithRequestType(ctx, poshttp.SpanRequest) + + urlV1, err := poshttp.MakeURL(c.UrlString, fetchSpanLatestV1, "") if err != nil { return nil, err } - return &response.Result, nil -} - -func (c *HttpClient) FetchSpan(ctx context.Context, spanID uint64) (*Span, error) { - url, err := poshttp.MakeURL(c.UrlString, fmt.Sprintf("bor/span/%d", spanID), "") + urlV2, err := poshttp.MakeURL(c.UrlString, fetchSpanLatestV2, "") if err != nil { - return nil, fmt.Errorf("%w, spanID=%d", err, spanID) + return nil, err } - ctx = poshttp.WithRequestType(ctx, poshttp.SpanRequest) - - if c.Version() == poshttp.HeimdallV2 { - url, err = poshttp.MakeURL(c.UrlString, fmt.Sprintf("bor/spans/%d", spanID), "") - if err != nil { - return nil, fmt.Errorf("%w, spanID=%d", err, spanID) - } - - response, err := poshttp.FetchWithRetry[SpanResponseV2](ctx, c.Client, url, c.Logger) - if err != nil { - return nil, fmt.Errorf("%w, spanID=%d", err, spanID) - } + return fetchVersioned(ctx, c, urlV1, urlV2, + func(response *SpanResponseV1) (*Span, error) { return &response.Result, nil }, + (*SpanResponseV2).ToSpan, + ) +} - return response.ToSpan() +func (c *HttpClient) FetchSpan(ctx context.Context, spanID uint64) (*Span, error) { + wrapErr := func(err error) error { return fmt.Errorf("%w, spanID=%d", err, spanID) } + urlV1, err := poshttp.MakeURL(c.UrlString, fmt.Sprintf("bor/span/%d", spanID), "") + if err != nil { + return nil, wrapErr(err) } - response, err := poshttp.FetchWithRetry[SpanResponseV1](ctx, c.Client, url, c.Logger) + urlV2, err := poshttp.MakeURL(c.UrlString, fmt.Sprintf("bor/spans/%d", spanID), "") if err != nil { - return nil, fmt.Errorf("%w, spanID=%d", err, spanID) + return nil, wrapErr(err) } - return &response.Result, nil + ctx = poshttp.WithRequestType(ctx, poshttp.SpanRequest) + + return fetchVersionedEx(ctx, c, urlV1, urlV2, + func(response *SpanResponseV1) (*Span, error) { return &response.Result, nil }, + (*SpanResponseV2).ToSpan, + fetchVersionedOpts{wrapFetchErr: wrapErr}, + ) } func (c *HttpClient) FetchSpans(ctx context.Context, page uint64, limit uint64) ([]*Span, error) { ctx = poshttp.WithRequestType(ctx, poshttp.CheckpointListRequest) - if c.Version() == poshttp.HeimdallV2 { - offset := (page - 1) * limit // page start from 1 - - url, err := poshttp.MakeURL(c.UrlString, fetchSpanListPathV2, fmt.Sprintf(fetchSpanListFormatV2, offset, limit)) - if err != nil { - return nil, err - } - - response, err := poshttp.FetchWithRetry[SpanListResponseV2](ctx, c.Client, url, c.Logger) - if err != nil { - return nil, err - } - - return response.ToList() - } - - url, err := poshttp.MakeURL(c.UrlString, fetchSpanListPathV1, fmt.Sprintf(fetchSpanListFormatV1, page, limit)) + urlV1, err := poshttp.MakeURL(c.UrlString, fetchSpanListPathV1, fmt.Sprintf(fetchSpanListFormatV1, page, limit)) if err != nil { return nil, err } - response, err := poshttp.FetchWithRetry[SpanListResponseV1](ctx, c.Client, url, c.Logger) + offset := (page - 1) * limit // page start from 1 + urlV2, err := poshttp.MakeURL(c.UrlString, fetchSpanListPathV2, fmt.Sprintf(fetchSpanListFormatV2, offset, limit)) if err != nil { return nil, err } - return response.Result, nil + return fetchVersioned(ctx, c, urlV1, urlV2, + func(response *SpanListResponseV1) ([]*Span, error) { return response.Result, nil }, + (*SpanListResponseV2).ToList, + ) } // FetchCheckpoint fetches the checkpoint from heimdall @@ -179,53 +197,30 @@ func (c *HttpClient) FetchCheckpoint(ctx context.Context, number int64) (*Checkp ctx = poshttp.WithRequestType(ctx, poshttp.CheckpointRequest) - if c.Version() == poshttp.HeimdallV2 { - response, err := poshttp.FetchWithRetry[CheckpointResponseV2](ctx, c.Client, url, c.Logger) - if err != nil { - return nil, err - } - - return response.ToCheckpoint(number) - } - - response, err := poshttp.FetchWithRetry[CheckpointResponseV1](ctx, c.Client, url, c.Logger) - if err != nil { - return nil, err - } - - return &response.Result, nil + return fetchVersioned(ctx, c, url, url, + func(response *CheckpointResponseV1) (*Checkpoint, error) { return &response.Result, nil }, + func(response *CheckpointResponseV2) (*Checkpoint, error) { return response.ToCheckpoint(number) }, + ) } func (c *HttpClient) FetchCheckpoints(ctx context.Context, page uint64, limit uint64) ([]*Checkpoint, error) { ctx = poshttp.WithRequestType(ctx, poshttp.CheckpointListRequest) - if c.Version() == poshttp.HeimdallV2 { - offset := (page - 1) * limit // page start from 1 - - url, err := poshttp.MakeURL(c.UrlString, fetchCheckpointList, fmt.Sprintf(fetchCheckpointListQueryFormatV2, offset, limit)) - if err != nil { - return nil, err - } - - response, err := poshttp.FetchWithRetry[CheckpointListResponseV2](ctx, c.Client, url, c.Logger) - if err != nil { - return nil, err - } - - return response.ToList() - } - - url, err := poshttp.MakeURL(c.UrlString, fetchCheckpointList, fmt.Sprintf(fetchCheckpointListQueryFormatV1, page, limit)) + urlV1, err := poshttp.MakeURL(c.UrlString, fetchCheckpointList, fmt.Sprintf(fetchCheckpointListQueryFormatV1, page, limit)) if err != nil { return nil, err } - response, err := poshttp.FetchWithRetry[CheckpointListResponseV1](ctx, c.Client, url, c.Logger) + offset := (page - 1) * limit // page start from 1 + urlV2, err := poshttp.MakeURL(c.UrlString, fetchCheckpointList, fmt.Sprintf(fetchCheckpointListQueryFormatV2, offset, limit)) if err != nil { return nil, err } - return response.Result, nil + return fetchVersioned(ctx, c, urlV1, urlV2, + func(response *CheckpointListResponseV1) ([]*Checkpoint, error) { return response.Result, nil }, + (*CheckpointListResponseV2).ToList, + ) } func isInvalidMilestoneIndexError(err error) bool { @@ -235,7 +230,12 @@ func isInvalidMilestoneIndexError(err error) bool { // FetchMilestone fetches a milestone from heimdall func (c *HttpClient) FetchMilestone(ctx context.Context, number int64) (*Milestone, error) { - url, err := milestoneURLv1(c.UrlString, number) + urlV1, err := milestoneURLv1(c.UrlString, number) + if err != nil { + return nil, err + } + + urlV2, err := milestoneURLv2(c.UrlString, number) if err != nil { return nil, err } @@ -266,34 +266,22 @@ func (c *HttpClient) FetchMilestone(ctx context.Context, number int64) (*Milesto return firstNum <= number && number <= firstNum+milestonePruneNumber-1 } - if c.Version() == poshttp.HeimdallV2 { - url, err := milestoneURLv2(c.UrlString, number) - if err != nil { - return nil, err - } - - response, err := poshttp.FetchWithRetryEx[MilestoneResponseV2](ctx, c.Client, url, isRecoverableError, c.Logger) - if err != nil { - if isInvalidMilestoneIndexError(err) { - return nil, fmt.Errorf("%w: number %d", ErrNotInMilestoneList, number) - } - return nil, err - } - - return response.ToMilestone(number) - } - - response, err := poshttp.FetchWithRetryEx[MilestoneResponseV1](ctx, c.Client, url, isRecoverableError, c.Logger) - if err != nil { - if isInvalidMilestoneIndexError(err) { - return nil, fmt.Errorf("%w: number %d", ErrNotInMilestoneList, number) - } - return nil, err - } - - response.Result.Id = MilestoneId(number) - - return &response.Result, nil + return fetchVersionedEx(ctx, c, urlV1, urlV2, + func(response *MilestoneResponseV1) (*Milestone, error) { + response.Result.Id = MilestoneId(number) + return &response.Result, nil + }, + func(response *MilestoneResponseV2) (*Milestone, error) { return response.ToMilestone(number) }, + fetchVersionedOpts{ + isRecoverableError: isRecoverableError, + wrapFetchErr: func(err error) error { + if isInvalidMilestoneIndexError(err) { + return fmt.Errorf("%w: number %d", ErrNotInMilestoneList, number) + } + return err + }, + }, + ) } func (c *HttpClient) FetchStatus(ctx context.Context) (*Status, error) { @@ -304,16 +292,10 @@ func (c *HttpClient) FetchStatus(ctx context.Context) (*Status, error) { ctx = poshttp.WithRequestType(ctx, poshttp.StatusRequest) - if c.Version() == poshttp.HeimdallV2 { - return poshttp.FetchWithRetry[Status](ctx, c.Client, url, c.Logger) - } - - response, err := poshttp.FetchWithRetry[StatusResponse](ctx, c.Client, url, c.Logger) - if err != nil { - return nil, err - } - - return &response.Result, nil + return fetchVersioned(ctx, c, url, url, + func(response *StatusResponse) (*Status, error) { return &response.Result, nil }, + func(status *Status) (*Status, error) { return status, nil }, + ) } // FetchCheckpointCount fetches the checkpoint count from heimdall @@ -325,62 +307,39 @@ func (c *HttpClient) FetchCheckpointCount(ctx context.Context) (int64, error) { ctx = poshttp.WithRequestType(ctx, poshttp.CheckpointCountRequest) - if c.Version() == poshttp.HeimdallV2 { - response, err := poshttp.FetchWithRetry[CheckpointCountResponseV2](ctx, c.Client, url, c.Logger) - if err != nil { - return 0, err - } - - count, err := strconv.Atoi(response.AckCount) - if err != nil { - return 0, err - } - - return int64(count), nil - } + return fetchVersioned(ctx, c, url, url, + func(response *CheckpointCountResponseV1) (int64, error) { return response.Result.Result, nil }, + func(response *CheckpointCountResponseV2) (int64, error) { return parseCount(response.AckCount) }, + ) +} - response, err := poshttp.FetchWithRetry[CheckpointCountResponseV1](ctx, c.Client, url, c.Logger) +// FetchMilestoneCount fetches the milestone count from heimdall +func (c *HttpClient) FetchMilestoneCount(ctx context.Context) (int64, error) { + urlV1, err := poshttp.MakeURL(c.UrlString, fetchMilestoneCountV1, "") if err != nil { return 0, err } - return response.Result.Result, nil -} - -// FetchMilestoneCount fetches the milestone count from heimdall -func (c *HttpClient) FetchMilestoneCount(ctx context.Context) (int64, error) { - url, err := poshttp.MakeURL(c.UrlString, fetchMilestoneCountV1, "") + urlV2, err := poshttp.MakeURL(c.UrlString, fetchMilestoneCountV2, "") if err != nil { return 0, err } ctx = poshttp.WithRequestType(ctx, poshttp.MilestoneCountRequest) - if c.Version() == poshttp.HeimdallV2 { - url, err := poshttp.MakeURL(c.UrlString, fetchMilestoneCountV2, "") - if err != nil { - return 0, err - } - - response, err := poshttp.FetchWithRetry[MilestoneCountResponseV2](ctx, c.Client, url, c.Logger) - if err != nil { - return 0, err - } - - count, err := strconv.Atoi(response.Count) - if err != nil { - return 0, err - } - - return int64(count), nil - } + return fetchVersioned(ctx, c, urlV1, urlV2, + func(response *MilestoneCountResponseV1) (int64, error) { return response.Result.Count, nil }, + func(response *MilestoneCountResponseV2) (int64, error) { return parseCount(response.Count) }, + ) +} - response, err := poshttp.FetchWithRetry[MilestoneCountResponseV1](ctx, c.Client, url, c.Logger) +func parseCount(count string) (int64, error) { + parsed, err := strconv.Atoi(count) if err != nil { return 0, err } - return response.Result.Count, nil + return int64(parsed), nil } // Heimdall keeps only this amount of latest milestones