diff --git a/go.mod b/go.mod index 1f1f72f8..7e7c58ce 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/ethereum/go-ethereum v1.16.2 github.com/golang/mock v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/hashicorp/go-retryablehttp v0.7.8 github.com/imdario/mergo v0.3.12 github.com/jellydator/ttlcache/v3 v3.3.0 github.com/libp2p/go-libp2p v0.38.2 @@ -71,7 +72,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect diff --git a/protocol/lighter/api.go b/protocol/lighter/api.go index ddd7eeab..851855c0 100644 --- a/protocol/lighter/api.go +++ b/protocol/lighter/api.go @@ -1,15 +1,24 @@ package lighter import ( + "bytes" + "context" "encoding/json" "fmt" "io" "net/http" "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/rs/zerolog/log" ) const ( - LIGHTER_URL = "https://mainnet.zklighter.elliot.ai/api" + LIGHTER_URL = "https://mainnet.zklighter.elliot.ai/api" + TX_NOT_FOUND_RETRIES = 3 + TX_NOT_FOUND_RETRY_WAIT = 500 * time.Millisecond + TX_NOT_FOUND_ERROR_CODE = 21500 + TX_FOUND_STATUS_CODE = 200 ) type TxType uint64 @@ -41,8 +50,8 @@ type LighterError struct { Message string `json:"message"` } -func (e *LighterError) Error() error { - return fmt.Errorf("lighter error: code %d, message: %s", e.Code, e.Message) +func (e *LighterError) Error() string { + return fmt.Sprintf("lighter error: code %d, message: %s", e.Code, e.Message) } func (tx *LighterTx) UnmarshalJSON(data []byte) error { @@ -54,9 +63,11 @@ func (tx *LighterTx) UnmarshalJSON(data []byte) error { if tx.Type == TxTypeL2Transfer { var t *Transfer if err := json.Unmarshal([]byte(tx.Info), &t); err != nil { - return err + return fmt.Errorf("failed to unmarshal info: %w", err) } tx.Transfer = t + } else { + return fmt.Errorf("unsupported transaction type: %d", tx.Type) } return nil @@ -67,13 +78,58 @@ type LighterAPI struct { } func NewLighterAPI() *LighterAPI { + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = TX_NOT_FOUND_RETRIES - 1 // RetryMax is a number of retries after an initial attempt + retryClient.RetryWaitMin = TX_NOT_FOUND_RETRY_WAIT + retryClient.RetryWaitMax = TX_NOT_FOUND_RETRY_WAIT + retryClient.CheckRetry = LighterCheckRetry + retryClient.Logger = log.Logger + return &LighterAPI{ - HTTPClient: &http.Client{ - Timeout: 10 * time.Second, - }, + HTTPClient: retryClient.StandardClient(), } } +// LighterCheckRetry checks if we should retry the request. +// Retries when: error code is TX_NOT_FOUND_ERROR_CODE (21500) or response has code 200 but missing/empty info. +func LighterCheckRetry(ctx context.Context, resp *http.Response, err error) (bool, error) { + if ctx.Err() != nil { + return false, ctx.Err() + } + + if err != nil { + return false, err + } + + if resp.StatusCode == http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + + // First try to unmarshal as LighterError + e := new(LighterError) + if err := json.Unmarshal(body, e); err == nil && e.Code == TX_NOT_FOUND_ERROR_CODE { + return true, nil + } + + // Check if it's a LighterTx with code 200 but missing info + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err == nil { + if code, ok := raw["code"].(float64); ok && code == TX_FOUND_STATUS_CODE { + info, hasInfo := raw["info"].(string) + if !hasInfo || info == "" { + return true, nil + } + } + } + } + + return false, nil +} + // GetTx fetches transaction from the lighter API func (a *LighterAPI) GetTx(hash string) (*LighterTx, error) { url := fmt.Sprintf("%s/v1/tx?by=hash&value=%s", LIGHTER_URL, hash) @@ -94,11 +150,7 @@ func (a *LighterAPI) GetTx(hash string) (*LighterTx, error) { s := new(LighterTx) if err := json.Unmarshal(body, s); err != nil { - e := new(LighterError) - if err := json.Unmarshal(body, e); err != nil { - return nil, fmt.Errorf("failed to unmarshal response body: %s, with error: %w", string(body), err) - } - return nil, e.Error() + return nil, err } return s, nil diff --git a/protocol/lighter/api_test.go b/protocol/lighter/api_test.go index 5906b780..12b36633 100644 --- a/protocol/lighter/api_test.go +++ b/protocol/lighter/api_test.go @@ -3,6 +3,7 @@ package lighter_test import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -113,3 +114,106 @@ func Test_LighterAPI_GetTx(t *testing.T) { }) } } + +func Test_LighterCheckRetry(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + inputError error + ctxCanceled bool + wantRetry bool + wantErr bool + }{ + { + name: "retry on TX_NOT_FOUND_ERROR_CODE", + statusCode: http.StatusOK, + responseBody: `{"code": 21500, "message": "transaction not found"}`, + wantRetry: true, + wantErr: false, + }, + { + name: "retry on code 200 with missing info", + statusCode: http.StatusOK, + responseBody: `{"code": 200, "hash": "0xabc", "type": 12}`, + wantRetry: true, + wantErr: false, + }, + { + name: "retry on code 200 with empty info", + statusCode: http.StatusOK, + responseBody: `{"code": 200, "hash": "0xabc", "type": 12, "info": ""}`, + wantRetry: true, + wantErr: false, + }, + { + name: "no retry on code 200 with valid info", + statusCode: http.StatusOK, + responseBody: `{"code": 200, "hash": "0xabc", "type": 12, "info": "{\"USDCAmount\": 1000}"}`, + wantRetry: false, + wantErr: false, + }, + { + name: "no retry on different error code", + statusCode: http.StatusOK, + responseBody: `{"code": 500, "message": "internal server error"}`, + wantRetry: false, + wantErr: false, + }, + { + name: "no retry on non-200 status", + statusCode: http.StatusNotFound, + responseBody: `{"error": "not found"}`, + wantRetry: false, + wantErr: false, + }, + { + name: "no retry on input error", + inputError: errors.New("connection error"), + wantRetry: false, + wantErr: true, + }, + { + name: "error on context cancellation", + ctxCanceled: true, + wantRetry: false, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + if tc.ctxCanceled { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + + var resp *http.Response + if tc.statusCode != 0 { + resp = &http.Response{ + StatusCode: tc.statusCode, + Body: io.NopCloser(bytes.NewReader([]byte(tc.responseBody))), + Header: make(http.Header), + } + } + + shouldRetry, err := lighter.LighterCheckRetry(ctx, resp, tc.inputError) + + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + + if shouldRetry != tc.wantRetry { + t.Errorf("expected retry=%v, got retry=%v", tc.wantRetry, shouldRetry) + } + }) + } +}