Files
nofx/trader/syncloop/syncloop_test.go
tinkle-community 953240565f fix(trader): stop order-sync goroutine leak and rate-limit hammering
Every StartOrderSync spawned a ticker goroutine that ran forever — it
survived trader stop AND deletion, so each quick-created trader left a
permanent 30s Hyperliquid poll behind. Stacked leaks turned into an
~8s effective hammer that tripped Hyperliquid's 429 rate limit, which
then broke the symbol board, trader creation, and order sync itself.

- new trader/syncloop package: shared stoppable sync loop with
  exponential failure backoff (30s base, 5min cap)
- all 9 exchanges' StartOrderSync now take the trader's stop channel
  and stop when the trader stops (close broadcast from AutoTrader.Stop)
- provider/hyperliquid: GetPerpDexCoins now serves a 5min TTL cache and
  falls back to the stale board when the upstream returns 429, so the
  symbol panel keeps working through rate limiting
2026-06-11 21:45:31 +08:00

79 lines
1.9 KiB
Go

package syncloop
import (
"errors"
"sync/atomic"
"testing"
"time"
)
func TestRunStopsWhenStopChannelCloses(t *testing.T) {
stop := make(chan struct{})
var calls atomic.Int64
Run(stop, 5*time.Millisecond, "test", func() error {
calls.Add(1)
return nil
})
// Let a few ticks happen, then stop.
time.Sleep(30 * time.Millisecond)
close(stop)
time.Sleep(20 * time.Millisecond)
after := calls.Load()
if after == 0 {
t.Fatal("sync function never ran")
}
// No further calls after stop.
time.Sleep(40 * time.Millisecond)
if calls.Load() != after {
t.Fatalf("sync kept running after stop: %d -> %d", after, calls.Load())
}
}
func TestRunBacksOffOnConsecutiveFailures(t *testing.T) {
stop := make(chan struct{})
defer close(stop)
var calls atomic.Int64
Run(stop, 10*time.Millisecond, "test", func() error {
calls.Add(1)
return errors.New("API returned status 429")
})
// With a 10ms base interval and exponential backoff (10, 20, 40, 80...),
// 100ms allows at most ~4 failing attempts. Without backoff there would
// be ~10.
time.Sleep(100 * time.Millisecond)
got := calls.Load()
if got == 0 {
t.Fatal("sync function never ran")
}
if got > 5 {
t.Fatalf("expected backoff to throttle failing sync, got %d calls in 100ms", got)
}
}
func TestRunRecoversIntervalAfterSuccess(t *testing.T) {
stop := make(chan struct{})
defer close(stop)
var calls atomic.Int64
// Fail twice, then succeed forever.
Run(stop, 5*time.Millisecond, "test", func() error {
n := calls.Add(1)
if n <= 2 {
return errors.New("transient")
}
return nil
})
// Failures at 5ms and 15ms (backoff 10ms), success at ~35ms (backoff 20ms),
// then the interval resets to 5ms — plenty of successful runs by 150ms.
time.Sleep(150 * time.Millisecond)
if got := calls.Load(); got < 8 {
t.Fatalf("expected interval to reset after success, got only %d calls", got)
}
}