Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 13 additions & 64 deletions sdk/fee_estimate_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ package hiero
// SPDX-License-Identifier: Apache-2.0

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/hiero-ledger/hiero-sdk-go/v2/proto/services"
"github.com/pkg/errors"
Expand Down Expand Up @@ -195,52 +193,21 @@ func (q *FeeEstimateQuery) callGetFeeEstimate(client *Client, protoTx *services.
url = fmt.Sprintf("%s&high_volume_throttle=%d", url, q.highVolumeThrottle)
}

var lastErr error
var resp *http.Response

for attempt := uint64(0); attempt < q.maxAttempts; attempt++ {
resp, err = http.Post(url, "application/protobuf", bytes.NewBuffer(txBytes)) // #nosec
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
break
}

switch {
case err != nil:
lastErr = err
case resp != nil:
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
lastErr = fmt.Errorf("received non-200 response: %d, details: %s", resp.StatusCode, body)
} else {
lastErr = fmt.Errorf("received non-200 response: %d", resp.StatusCode)
}
default:
lastErr = fmt.Errorf("received nil response")
}

// Check if we should retry
if !q.shouldRetry(err, resp) {
return FeeEstimateResponse{}, errors.Wrap(lastErr, "failed to call fee estimate API")
}

// Exponential backoff capped at 8s; exp is clamped to avoid shift overflow.
exp := min(attempt, uint64(5))
delayMs := 250.0 * float64(uint64(1)<<exp)
if delayMs > 8000 {
delayMs = 8000
}

// Wait before retry
select {
case <-client.networkUpdateContext.Done():
return FeeEstimateResponse{}, client.networkUpdateContext.Err()
case <-time.After(time.Duration(delayMs) * time.Millisecond):
}
// Timeout 0 preserves the previous no-per-request-timeout behaviour.
resp, err := mirrorNodePostWithRetry(client, url, "application/protobuf", txBytes, q.maxAttempts, 0)
if err != nil {
return FeeEstimateResponse{}, errors.Wrapf(err, "failed to call fee estimate API after %d attempts", q.maxAttempts)
}

if resp == nil {
return FeeEstimateResponse{}, errors.Wrapf(lastErr, "failed to call fee estimate API after %d attempts", q.maxAttempts)
return FeeEstimateResponse{}, errors.Wrap(errors.New("received nil response"), "failed to call fee estimate API")
}
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil {
return FeeEstimateResponse{}, errors.Wrap(fmt.Errorf("received non-200 response: %d, details: %s", resp.StatusCode, body), "failed to call fee estimate API")
}
return FeeEstimateResponse{}, errors.Wrap(fmt.Errorf("received non-200 response: %d", resp.StatusCode), "failed to call fee estimate API")
}

defer resp.Body.Close()
Expand All @@ -258,24 +225,6 @@ func (q *FeeEstimateQuery) callGetFeeEstimate(client *Client, protoTx *services.
return response, nil
}

// shouldRetry determines if an error should be retried
func (q *FeeEstimateQuery) shouldRetry(err error, resp *http.Response) bool {
if err == nil && resp != nil {
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
return true
}
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return false
}
}

if err == nil {
return false
}

return true
}

// validateNetworkOnIDs validates network and IDs on the query
func (q *FeeEstimateQuery) validateNetworkOnIDs(client *Client) error {
if client == nil || !client.autoValidateChecksums {
Expand Down
31 changes: 31 additions & 0 deletions sdk/fee_estimate_query_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,37 @@ func TestUnitFeeEstimateQuerySendsHighVolumeThrottle(t *testing.T) {
"highVolumeMultiplier should be >= 1 when throttle is non-zero")
}

func TestUnitFeeEstimateQueryReturnsErrorAfterTransportFailures(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
// Drop the connection on every attempt, surfacing as a transport error (EOF).
// After the retries are exhausted the query must return a wrapped error rather
// than a response.
hj, ok := w.(http.Hijacker)
require.True(t, ok, "test server must support connection hijacking")
conn, _, hijackErr := hj.Hijack()
require.NoError(t, hijackErr)
_ = conn.Close()
}))
defer server.Close()

cleanup := SetupMockTransportForDomain("localhost:8084", server.URL)
defer cleanup()

client := newMockClientForREST()

tx := NewTransferTransaction()

_, err := NewFeeEstimateQuery().
SetTransaction(tx).
SetMaxAttempts(2).
Execute(client)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to call fee estimate API after 2 attempts")
assert.Equal(t, 2, requestCount, "transport errors should be retried up to maxAttempts")
}

func TestUnitFeeEstimateQueryDoesNotRetryOn400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
20 changes: 9 additions & 11 deletions sdk/fee_estimate_query_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,33 +100,31 @@ func TestUnitFeeEstimateQueryExecuteWithoutClient(t *testing.T) {
func TestUnitFeeEstimateQueryShouldRetry(t *testing.T) {
t.Parallel()

query := NewFeeEstimateQuery()

require.False(t, query.shouldRetry(nil, nil))
require.False(t, mirrorNodeShouldRetry(nil, nil))

resp200 := &http.Response{StatusCode: http.StatusOK}
require.False(t, query.shouldRetry(nil, resp200))
require.False(t, mirrorNodeShouldRetry(nil, resp200))

resp500 := &http.Response{StatusCode: http.StatusInternalServerError}
require.True(t, query.shouldRetry(nil, resp500))
require.True(t, mirrorNodeShouldRetry(nil, resp500))

resp503 := &http.Response{StatusCode: http.StatusServiceUnavailable}
require.True(t, query.shouldRetry(nil, resp503))
require.True(t, mirrorNodeShouldRetry(nil, resp503))

resp429 := &http.Response{StatusCode: http.StatusTooManyRequests}
require.True(t, query.shouldRetry(nil, resp429))
require.True(t, mirrorNodeShouldRetry(nil, resp429))

resp400 := &http.Response{StatusCode: http.StatusBadRequest}
require.False(t, query.shouldRetry(nil, resp400))
require.False(t, mirrorNodeShouldRetry(nil, resp400))

resp404 := &http.Response{StatusCode: http.StatusNotFound}
require.False(t, query.shouldRetry(nil, resp404))
require.False(t, mirrorNodeShouldRetry(nil, resp404))

err := errors.New("connection refused")
require.True(t, query.shouldRetry(err, nil))
require.True(t, mirrorNodeShouldRetry(err, nil))

err = errors.New("timeout")
require.True(t, query.shouldRetry(err, nil))
require.True(t, mirrorNodeShouldRetry(err, nil))
}

func TestUnitFeeEstimateResponseFromREST(t *testing.T) {
Expand Down
6 changes: 4 additions & 2 deletions sdk/mirror_node_contract_query.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package hiero

import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -215,10 +214,13 @@ func (mirrorNodeContractQuery *mirrorNodeContractQuery) performContractCallToMir

mirrorUrl = fmt.Sprintf("%s/contracts/call", mirrorUrl)

resp, err := http.Post(mirrorUrl, "application/json", bytes.NewBuffer([]byte(jsonPayload))) // #nosec
resp, err := mirrorNodePostWithRetry(client, mirrorUrl, "application/json", []byte(jsonPayload), mirrorNodeDefaultMaxAttempts, mirrorNodeDefaultTimeout)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
if resp == nil {
return nil, errors.New("received nil response from Mirror Node")
}

defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
Expand Down
102 changes: 102 additions & 0 deletions sdk/mirror_node_contract_query_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -205,6 +206,107 @@ func int64Ptr(i int64) *int64 {
return &i
}

func TestUnitMirrorNodeContractQueryRetriesTransientErrors(t *testing.T) {
// Note: Not running in parallel since we modify global http.DefaultTransport
const domain = "retrytransient.example.com:443"

var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Transient 5xx on the first two attempts, then succeed (issue #1752).
if atomic.AddInt32(&attempts, 1) < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{"result": "0x5208"}))
}))
defer server.Close()

cleanup := SetupMockTransportForDomain(domain, server.URL)
defer cleanup()

client, err := _NewMockClient()
require.NoError(t, err)
client.SetLedgerID(*NewLedgerIDTestnet())
client.SetMirrorNetwork([]string{domain})

gas, err := NewMirrorNodeContractEstimateGasQuery().
SetContractEvmAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e").
SetFunction("testFunction", NewContractFunctionParameters().AddString("test")).
Execute(client)
require.NoError(t, err)
assert.Equal(t, uint64(21000), gas)
assert.Equal(t, int32(3), atomic.LoadInt32(&attempts), "transient failures should be retried until success")
}

func TestUnitMirrorNodeContractQueryRetriesTransportErrors(t *testing.T) {
// Note: Not running in parallel since we modify global http.DefaultTransport
const domain = "retrytransport.example.com:443"

var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Drop the connection on the first two attempts, surfacing as a transport error
// (EOF) — the failure mode from issue #1752 — then succeed on the third.
if atomic.AddInt32(&attempts, 1) < 3 {
hj, ok := w.(http.Hijacker)
require.True(t, ok, "test server must support connection hijacking")
conn, _, hijackErr := hj.Hijack()
require.NoError(t, hijackErr)
_ = conn.Close()
return
}
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{"result": "0x5208"}))
}))
defer server.Close()

cleanup := SetupMockTransportForDomain(domain, server.URL)
defer cleanup()

client, err := _NewMockClient()
require.NoError(t, err)
client.SetLedgerID(*NewLedgerIDTestnet())
client.SetMirrorNetwork([]string{domain})

gas, err := NewMirrorNodeContractEstimateGasQuery().
SetContractEvmAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e").
SetFunction("testFunction", NewContractFunctionParameters().AddString("test")).
Execute(client)
require.NoError(t, err)
assert.Equal(t, uint64(21000), gas)
assert.Equal(t, int32(3), atomic.LoadInt32(&attempts), "transport errors should be retried until success")
}

func TestUnitMirrorNodeContractQueryDoesNotRetryNon200(t *testing.T) {
// Note: Not running in parallel since we modify global http.DefaultTransport
const domain = "no4xxretry.example.com:443"

var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
// A 4xx (e.g. gas limit too low) is a genuine result, not a transient failure,
// so it must be returned without retrying.
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"_status":{"messages":[{"message":"gas limit too low"}]}}`))
}))
defer server.Close()

cleanup := SetupMockTransportForDomain(domain, server.URL)
defer cleanup()

client, err := _NewMockClient()
require.NoError(t, err)
client.SetLedgerID(*NewLedgerIDTestnet())
client.SetMirrorNetwork([]string{domain})

_, err = NewMirrorNodeContractEstimateGasQuery().
SetContractEvmAddress("0x742d35Cc6634C0532925a3b844Bc454e4438f44e").
SetFunction("testFunction", NewContractFunctionParameters().AddString("test")).
Execute(client)
require.ErrorContains(t, err, "received non-200 response from Mirror Node")
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "non-200 responses must not be retried")
}

func TestUnitMirrorNodeContractQueryWithDifferentPorts(t *testing.T) {
// Note: Not running in parallel since we modify global http.DefaultTransport

Expand Down
Loading
Loading