Skip to content

Commit 0f7a23b

Browse files
authored
Cache custom-query JSON responses to avoid API rate limits (#40)
* reporter: cache custom-query JSON responses (TTL + singleflight) to avoid price-source API rate limits * cache: fix lint — gci import order + 'behaviour'->'behavior' (misspell)
1 parent 13b9ce9 commit 0f7a23b

5 files changed

Lines changed: 312 additions & 2 deletions

File tree

custom_query/contracts/metrics/metrics.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ var (
4040
Buckets: prometheus.DefBuckets,
4141
})
4242

43+
// Response-cache metrics (see rpc_reader/cache.go). Useful to confirm the
44+
// cache is actually shielding upstream APIs from rate limits.
45+
RPCCacheHits = promauto.NewCounter(prometheus.CounterOpts{
46+
Name: "rpc_reader_cache_hits_total",
47+
Help: "Total number of RPC responses served from the in-memory cache",
48+
})
49+
50+
RPCCacheMisses = promauto.NewCounter(prometheus.CounterOpts{
51+
Name: "rpc_reader_cache_misses_total",
52+
Help: "Total number of RPC cache misses that triggered an upstream fetch",
53+
})
54+
4355
// Health check metrics (used by both contract and RPC readers)
4456
RPCHealthCheckFailures = promauto.NewCounterVec(prometheus.CounterOpts{
4557
Name: "reader_health_check_failures_total",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package rpc_reader
2+
3+
import (
4+
"context"
5+
"os"
6+
"sort"
7+
"strings"
8+
"sync"
9+
"time"
10+
11+
"github.com/tellor-io/layer-daemons/custom_query/contracts/metrics"
12+
"golang.org/x/sync/singleflight"
13+
)
14+
15+
// The reporter polls the cyclelist roughly every 200ms, so a single custom
16+
// query can re-hit the same upstream JSON API (CoinGecko, CoinMarketCap, the
17+
// Uniswap/The Graph subgraph, osmosis, etc.) several times per second. With
18+
// free-tier API keys that quickly trips rate limits (HTTP 429) and produces the
19+
// "report generation failed" bursts.
20+
//
21+
// This cache sits in front of rpc_reader.FetchJSON and serves an identical
22+
// request from memory for up to CUSTOM_QUERY_CACHE_TTL, collapsing those
23+
// redundant calls into one upstream fetch per interval. Concurrent identical
24+
// requests are coalesced via singleflight so a cache miss can never cause a
25+
// stampede.
26+
//
27+
// Tuning (env vars, read once at startup):
28+
// - CUSTOM_QUERY_CACHE_TTL : cache freshness window, a Go duration (e.g. "3s",
29+
// "500ms"). Set to "0" to disable the cache entirely (pure pass-through to
30+
// the previous behavior). Defaults to defaultCacheTTL.
31+
//
32+
// Correctness note: the TTL bounds how stale a reported price can be, so it must
33+
// stay well under the chain's value-freshness expectations. The default is
34+
// deliberately small.
35+
const (
36+
cacheTTLEnv = "CUSTOM_QUERY_CACHE_TTL"
37+
defaultCacheTTL = 3 * time.Second
38+
)
39+
40+
type cacheEntry struct {
41+
body []byte
42+
fetchedAt time.Time
43+
}
44+
45+
type responseCache struct {
46+
ttl time.Duration
47+
mu sync.RWMutex
48+
items map[string]cacheEntry
49+
group singleflight.Group
50+
}
51+
52+
// sharedCache is process-wide: readers are rebuilt per query config, so the
53+
// cache must outlive any single Reader instance to be effective across cycles.
54+
var sharedCache = newResponseCache(cacheTTLFromEnv())
55+
56+
func cacheTTLFromEnv() time.Duration {
57+
v := strings.TrimSpace(os.Getenv(cacheTTLEnv))
58+
if v == "" {
59+
return defaultCacheTTL
60+
}
61+
d, err := time.ParseDuration(v)
62+
if err != nil || d < 0 {
63+
return defaultCacheTTL
64+
}
65+
return d // 0 => disabled
66+
}
67+
68+
func newResponseCache(ttl time.Duration) *responseCache {
69+
return &responseCache{ttl: ttl, items: make(map[string]cacheEntry)}
70+
}
71+
72+
func (c *responseCache) enabled() bool { return c.ttl > 0 }
73+
74+
// get returns a cached body if present and still within the TTL.
75+
func (c *responseCache) get(key string) ([]byte, bool) {
76+
c.mu.RLock()
77+
e, ok := c.items[key]
78+
c.mu.RUnlock()
79+
if !ok || time.Since(e.fetchedAt) > c.ttl {
80+
return nil, false
81+
}
82+
return e.body, true
83+
}
84+
85+
func (c *responseCache) set(key string, body []byte) {
86+
c.mu.Lock()
87+
c.items[key] = cacheEntry{body: body, fetchedAt: time.Now()}
88+
c.mu.Unlock()
89+
}
90+
91+
// cacheKey uniquely identifies an HTTP request. It must capture everything that
92+
// can change the response: method, URL (which carries API-key query params),
93+
// POST body (GraphQL queries), and headers (which may carry API keys).
94+
func (r *Reader) cacheKey() string {
95+
var b strings.Builder
96+
b.WriteString(r.client.method)
97+
b.WriteByte('\n')
98+
b.WriteString(r.client.baseURL)
99+
b.WriteByte('\n')
100+
b.WriteString(r.Query)
101+
b.WriteByte('\n')
102+
103+
keys := make([]string, 0, len(r.Headers))
104+
for k := range r.Headers {
105+
keys = append(keys, k)
106+
}
107+
sort.Strings(keys)
108+
for _, k := range keys {
109+
b.WriteString(k)
110+
b.WriteByte('=')
111+
b.WriteString(r.Headers[k])
112+
b.WriteByte('\n')
113+
}
114+
return b.String()
115+
}
116+
117+
// FetchJSON returns the JSON response for the reader's request, served from the
118+
// in-memory cache when a fresh copy exists. When caching is disabled it falls
119+
// straight through to the underlying retrying fetch.
120+
//
121+
// The returned []byte may be shared with other callers; treat it as read-only
122+
// (all current callers only json.Unmarshal it, which does not mutate).
123+
func (r *Reader) FetchJSON(ctx context.Context) ([]byte, error) {
124+
if !sharedCache.enabled() {
125+
return r.fetchWithRetry(ctx)
126+
}
127+
128+
key := r.cacheKey()
129+
if body, ok := sharedCache.get(key); ok {
130+
metrics.RPCCacheHits.Inc()
131+
return body, nil
132+
}
133+
metrics.RPCCacheMisses.Inc()
134+
135+
v, err, _ := sharedCache.group.Do(key, func() (interface{}, error) {
136+
// Re-check: a concurrent leader may have just populated the cache.
137+
if body, ok := sharedCache.get(key); ok {
138+
return body, nil
139+
}
140+
body, err := r.fetchWithRetry(ctx)
141+
if err != nil {
142+
return nil, err
143+
}
144+
sharedCache.set(key, body)
145+
return body, nil
146+
})
147+
if err != nil {
148+
return nil, err
149+
}
150+
return v.([]byte), nil
151+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package rpc_reader
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync"
8+
"sync/atomic"
9+
"testing"
10+
"time"
11+
)
12+
13+
// withSharedCache swaps the process-wide cache for the duration of a test.
14+
func withSharedCache(t *testing.T, ttl time.Duration) {
15+
t.Helper()
16+
prev := sharedCache
17+
sharedCache = newResponseCache(ttl)
18+
t.Cleanup(func() { sharedCache = prev })
19+
}
20+
21+
// countingServer returns an httptest server that records how many times it was hit.
22+
func countingServer(t *testing.T, body string) (*httptest.Server, *int32) {
23+
t.Helper()
24+
var hits int32
25+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
26+
atomic.AddInt32(&hits, 1)
27+
_, _ = w.Write([]byte(body))
28+
}))
29+
t.Cleanup(srv.Close)
30+
return srv, &hits
31+
}
32+
33+
// newTestReader builds a reader with a generous timeout (NewReader treats the
34+
// timeout arg as seconds for the client and ms for the per-attempt context).
35+
func newTestReader(t *testing.T, url string) *Reader {
36+
t.Helper()
37+
r, err := NewReader(url, http.MethodGet, "", nil, nil, 5000, nil)
38+
if err != nil {
39+
t.Fatalf("NewReader: %v", err)
40+
}
41+
return r
42+
}
43+
44+
func TestFetchJSON_ServesFromCacheWithinTTL(t *testing.T) {
45+
withSharedCache(t, time.Minute)
46+
srv, hits := countingServer(t, `{"price":1}`)
47+
r := newTestReader(t, srv.URL)
48+
49+
for i := 0; i < 5; i++ {
50+
body, err := r.FetchJSON(context.Background())
51+
if err != nil {
52+
t.Fatalf("FetchJSON: %v", err)
53+
}
54+
if string(body) != `{"price":1}` {
55+
t.Fatalf("unexpected body: %s", body)
56+
}
57+
}
58+
if got := atomic.LoadInt32(hits); got != 1 {
59+
t.Fatalf("expected 1 upstream hit, got %d", got)
60+
}
61+
}
62+
63+
func TestFetchJSON_RefetchesAfterTTL(t *testing.T) {
64+
withSharedCache(t, 20*time.Millisecond)
65+
srv, hits := countingServer(t, `{"price":1}`)
66+
r := newTestReader(t, srv.URL)
67+
68+
if _, err := r.FetchJSON(context.Background()); err != nil {
69+
t.Fatalf("FetchJSON: %v", err)
70+
}
71+
time.Sleep(40 * time.Millisecond)
72+
if _, err := r.FetchJSON(context.Background()); err != nil {
73+
t.Fatalf("FetchJSON: %v", err)
74+
}
75+
if got := atomic.LoadInt32(hits); got != 2 {
76+
t.Fatalf("expected 2 upstream hits after TTL expiry, got %d", got)
77+
}
78+
}
79+
80+
func TestFetchJSON_DisabledPassesThrough(t *testing.T) {
81+
withSharedCache(t, 0)
82+
srv, hits := countingServer(t, `{"price":1}`)
83+
r := newTestReader(t, srv.URL)
84+
85+
for i := 0; i < 3; i++ {
86+
if _, err := r.FetchJSON(context.Background()); err != nil {
87+
t.Fatalf("FetchJSON: %v", err)
88+
}
89+
}
90+
if got := atomic.LoadInt32(hits); got != 3 {
91+
t.Fatalf("expected 3 upstream hits when cache disabled, got %d", got)
92+
}
93+
}
94+
95+
func TestFetchJSON_CoalescesConcurrentRequests(t *testing.T) {
96+
withSharedCache(t, time.Minute)
97+
var hits int32
98+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
99+
atomic.AddInt32(&hits, 1)
100+
time.Sleep(80 * time.Millisecond) // hold the connection so callers overlap
101+
_, _ = w.Write([]byte(`{"price":1}`))
102+
}))
103+
t.Cleanup(srv.Close)
104+
r := newTestReader(t, srv.URL)
105+
106+
var wg sync.WaitGroup
107+
for i := 0; i < 10; i++ {
108+
wg.Add(1)
109+
go func() {
110+
defer wg.Done()
111+
if _, err := r.FetchJSON(context.Background()); err != nil {
112+
t.Errorf("FetchJSON: %v", err)
113+
}
114+
}()
115+
}
116+
wg.Wait()
117+
if got := atomic.LoadInt32(&hits); got != 1 {
118+
t.Fatalf("expected concurrent requests to coalesce into 1 upstream hit, got %d", got)
119+
}
120+
}
121+
122+
func TestCacheKey_DistinctByURLQueryAndHeaders(t *testing.T) {
123+
mk := func(url, query string, headers map[string]string) string {
124+
r, err := NewReader(url, http.MethodGet, query, headers, nil, 5000, nil)
125+
if err != nil {
126+
t.Fatalf("NewReader: %v", err)
127+
}
128+
return r.cacheKey()
129+
}
130+
base := mk("http://x/a", "", nil)
131+
cases := map[string]string{
132+
"different url": mk("http://x/b", "", nil),
133+
"different query": mk("http://x/a", "{q}", nil),
134+
"different header": mk("http://x/a", "", map[string]string{"k": "v"}),
135+
}
136+
for name, key := range cases {
137+
if key == base {
138+
t.Errorf("cacheKey should differ for %s but matched base", name)
139+
}
140+
}
141+
// Same inputs must produce the same key.
142+
if mk("http://x/a", "", nil) != base {
143+
t.Error("cacheKey not stable for identical inputs")
144+
}
145+
}

custom_query/rpc/rpc_reader/reader.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ func cloneStringMap(m map[string]string) map[string]string {
7070
return out
7171
}
7272

73-
func (r *Reader) FetchJSON(ctx context.Context) ([]byte, error) {
73+
// fetchWithRetry performs the actual HTTP fetch (with retries). FetchJSON
74+
// (see cache.go) wraps this with the optional in-memory response cache.
75+
func (r *Reader) fetchWithRetry(ctx context.Context) ([]byte, error) {
7476
startTime := time.Now()
7577
defer func() {
7678
metrics.RPCCallDuration.Observe(time.Since(startTime).Seconds())

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ require (
233233
golang.org/x/crypto v0.46.0
234234
golang.org/x/net v0.48.0 // indirect
235235
golang.org/x/oauth2 v0.26.0 // indirect
236-
golang.org/x/sync v0.19.0 // indirect
236+
golang.org/x/sync v0.19.0
237237
golang.org/x/sys v0.39.0 // indirect
238238
golang.org/x/term v0.38.0 // indirect
239239
golang.org/x/time v0.9.0 // indirect

0 commit comments

Comments
 (0)