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 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) + } + }) + } +}