Skip to content

Commit

Permalink
Cache response headers along with json-rpc response (#54)
Browse files Browse the repository at this point in the history
  • Loading branch information
evgeniy-scherbina authored Nov 2, 2023
1 parent 21901ff commit 346a479
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 18 deletions.
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ CACHE_INDEFINITELY=false
# Possible values are testnet, mainnet, etc...
# CACHE_PREFIX must not contain colon symbol
CACHE_PREFIX=local-chain
# WHITELISTED_HEADERS contains comma-separated list of headers which has to be cached along with EVM JSON-RPC response
WHITELISTED_HEADERS=access-control-expose-headers,server,vary

##### Database Config
POSTGRES_PASSWORD=password
Expand Down
10 changes: 10 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Config struct {
RedisPassword string
CacheTTL time.Duration
CachePrefix string
WhitelistedHeaders []string
}

const (
Expand Down Expand Up @@ -109,6 +110,7 @@ const (
CACHE_INDEFINITELY_KEY = "CACHE_INDEFINITELY"
CACHE_TTL_ENVIRONMENT_KEY = "CACHE_TTL_SECONDS"
CACHE_PREFIX_ENVIRONMENT_KEY = "CACHE_PREFIX"
WHITELISTED_HEADERS_ENVIRONMENT_KEY = "WHITELISTED_HEADERS"
)

var ErrEmptyHostMap = errors.New("backend host url map is empty")
Expand Down Expand Up @@ -214,6 +216,13 @@ func ReadConfig() Config {
parsedProxyBackendHostURLMap, _ := ParseRawProxyBackendHostURLMap(rawProxyBackendHostURLMap)
parsedProxyPruningBackendHostURLMap, _ := ParseRawProxyBackendHostURLMap(rawProxyPruningBackendHostURLMap)

whitelistedHeaders := os.Getenv(WHITELISTED_HEADERS_ENVIRONMENT_KEY)
parsedWhitelistedHeaders := strings.Split(whitelistedHeaders, ",")
// strings.Split("", sep) returns []string{""} (slice with one empty string) which can be unexpected, so override it with more reasonable behaviour
if whitelistedHeaders == "" {
parsedWhitelistedHeaders = []string{}
}

return Config{
ProxyServicePort: os.Getenv(PROXY_SERVICE_PORT_ENVIRONMENT_KEY),
LogLevel: EnvOrDefault(LOG_LEVEL_ENVIRONMENT_KEY, DEFAULT_LOG_LEVEL),
Expand Down Expand Up @@ -252,5 +261,6 @@ func ReadConfig() Config {
CacheTTL: time.Duration(EnvOrDefaultInt(CACHE_TTL_ENVIRONMENT_KEY, 0)) * time.Second,
CacheIndefinitely: EnvOrDefaultBool(CACHE_INDEFINITELY_KEY, false),
CachePrefix: os.Getenv(CACHE_PREFIX_ENVIRONMENT_KEY),
WhitelistedHeaders: parsedWhitelistedHeaders,
}
}
37 changes: 31 additions & 6 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,9 @@ func TestE2ETestCachingMdwWithBlockNumberParam(t *testing.T) {
// check that cached and non-cached responses are equal

// eth_getBlockByNumber - cache MISS
resp1 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params)
require.Equal(t, cachemdw.CacheMissHeaderValue, resp1.Header[cachemdw.CacheHeaderKey][0])
body1, err := io.ReadAll(resp1.Body)
cacheMissResp := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params)
require.Equal(t, cachemdw.CacheMissHeaderValue, cacheMissResp.Header[cachemdw.CacheHeaderKey][0])
body1, err := io.ReadAll(cacheMissResp.Body)
require.NoError(t, err)
err = checkJsonRpcErr(body1)
require.NoError(t, err)
Expand All @@ -491,16 +491,20 @@ func TestE2ETestCachingMdwWithBlockNumberParam(t *testing.T) {
containsKey(t, redisClient, expectedKey)

// eth_getBlockByNumber - cache HIT
resp2 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params)
require.Equal(t, cachemdw.CacheHitHeaderValue, resp2.Header[cachemdw.CacheHeaderKey][0])
body2, err := io.ReadAll(resp2.Body)
cacheHitResp := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params)
require.Equal(t, cachemdw.CacheHitHeaderValue, cacheHitResp.Header[cachemdw.CacheHeaderKey][0])
body2, err := io.ReadAll(cacheHitResp.Body)
require.NoError(t, err)
err = checkJsonRpcErr(body2)
require.NoError(t, err)
expectKeysNum(t, redisClient, tc.keysNum)
containsKey(t, redisClient, expectedKey)

// check that response bodies are the same
require.JSONEq(t, string(body1), string(body2), "blocks should be the same")

// check that response headers are the same
equalHeaders(t, cacheMissResp.Header, cacheHitResp.Header)
})
}

Expand All @@ -526,6 +530,27 @@ func TestE2ETestCachingMdwWithBlockNumberParam(t *testing.T) {
cleanUpRedis(t, redisClient)
}

// equalHeaders checks that headers of headersMap1 and headersMap2 are equal
// NOTE: it completely ignores presence/absence of cachemdw.CacheHeaderKey,
// it's done in that way to allow comparison of headers for cache miss and cache hit cases
func equalHeaders(t *testing.T, headersMap1, headersMap2 http.Header) {
containsHeaders(t, headersMap1, headersMap2)
containsHeaders(t, headersMap2, headersMap1)
}

// containsHeaders checks that headersMap1 contains all headers from headersMap2 and that values for headers are the same
// NOTE: it completely ignores presence/absence of cachemdw.CacheHeaderKey,
// it's done in that way to allow comparison of headers for cache miss and cache hit cases
func containsHeaders(t *testing.T, headersMap1, headersMap2 http.Header) {
for name, value := range headersMap1 {
if name == cachemdw.CacheHeaderKey {
continue
}

require.Equal(t, value, headersMap2[name])
}
}

func TestE2ETestCachingMdwWithBlockNumberParam_Metrics(t *testing.T) {
client, err := ethclient.Dial(proxyServiceURL)
require.NoError(t, err)
Expand Down
49 changes: 41 additions & 8 deletions service/cachemdw/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ type ServiceCache struct {
cacheIndefinitely bool
decodedRequestContextKey any
// cachePrefix is used as prefix for any key in the cache
cachePrefix string
cacheEnabled bool
cachePrefix string
cacheEnabled bool
whitelistedHeaders []string

*logging.ServiceLogger
}
Expand All @@ -38,6 +39,7 @@ func NewServiceCache(
decodedRequestContextKey any,
cachePrefix string,
cacheEnabled bool,
whitelistedHeaders []string,
logger *logging.ServiceLogger,
) *ServiceCache {
return &ServiceCache{
Expand All @@ -48,10 +50,19 @@ func NewServiceCache(
decodedRequestContextKey: decodedRequestContextKey,
cachePrefix: cachePrefix,
cacheEnabled: cacheEnabled,
whitelistedHeaders: whitelistedHeaders,
ServiceLogger: logger,
}
}

// QueryResponse represents the structure which stored in the cache for every cacheable request
type QueryResponse struct {
// JsonRpcResponseResult is an EVM JSON-RPC response's result
JsonRpcResponseResult []byte `json:"json_rpc_response_result"`
// HeaderMap is a map of HTTP headers which is cached along with the EVM JSON-RPC response
HeaderMap map[string]string `json:"header_map"`
}

// IsCacheable checks if EVM request is cacheable.
// In current implementation we consider request is cacheable if it has specific block height
func IsCacheable(
Expand Down Expand Up @@ -89,7 +100,7 @@ func IsCacheable(
func (c *ServiceCache) GetCachedQueryResponse(
ctx context.Context,
req *decode.EVMRPCRequestEnvelope,
) ([]byte, error) {
) (*QueryResponse, error) {
// if request isn't cacheable - there is no point to try to get it from cache so exit early with an error
cacheable := IsCacheable(c.ServiceLogger, req)
if !cacheable {
Expand All @@ -101,25 +112,36 @@ func (c *ServiceCache) GetCachedQueryResponse(
return nil, err
}

// get JSON-RPC response's result from the cache
result, err := c.cacheClient.Get(ctx, key)
// get Query Response from the cache
queryResponseInJSON, err := c.cacheClient.Get(ctx, key)
if err != nil {
return nil, err
}

// Query Response consists of JSON-RPC response's result and headers map.
// Unmarshal it and later update JSON-RPC response's result to match JSON-RPC request.
var queryResponse QueryResponse
if err := json.Unmarshal(queryResponseInJSON, &queryResponse); err != nil {
return nil, err
}

// JSON-RPC response's ID and Version should match JSON-RPC request
id := strconv.Itoa(int(req.ID))
response := JsonRpcResponse{
Version: req.JSONRPCVersion,
ID: []byte(id),
Result: result,
Result: queryResponse.JsonRpcResponseResult,
}
responseInJSON, err := json.Marshal(response)
if err != nil {
return nil, err
}
responseInJSON = append(responseInJSON, '\n')

// update JSON-RPC response's result before returning Query Response
queryResponse.JsonRpcResponseResult = responseInJSON

return responseInJSON, nil
return &queryResponse, nil
}

// CacheQueryResponse calculates cache key for request and then saves response to the cache.
Expand All @@ -130,6 +152,7 @@ func (c *ServiceCache) CacheQueryResponse(
ctx context.Context,
req *decode.EVMRPCRequestEnvelope,
responseInBytes []byte,
headerMap map[string]string,
) error {
// don't cache uncacheable requests
if !IsCacheable(c.ServiceLogger, req) {
Expand All @@ -150,7 +173,17 @@ func (c *ServiceCache) CacheQueryResponse(
return err
}

return c.cacheClient.Set(ctx, key, response.Result, c.cacheTTL, c.cacheIndefinitely)
// cache JSON-RPC response's result and HTTP Header Map
queryResponse := &QueryResponse{
JsonRpcResponseResult: response.Result,
HeaderMap: headerMap,
}
queryResponseInJSON, err := json.Marshal(queryResponse)
if err != nil {
return err
}

return c.cacheClient.Set(ctx, key, queryResponseInJSON, c.cacheTTL, c.cacheIndefinitely)
}

func (c *ServiceCache) Healthcheck(ctx context.Context) error {
Expand Down
5 changes: 3 additions & 2 deletions service/cachemdw/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func TestUnitTestCacheQueryResponse(t *testing.T) {
service.DecodedRequestContextKey,
defaultCachePrefixString,
true,
[]string{},
&logger,
)

Expand All @@ -90,12 +91,12 @@ func TestUnitTestCacheQueryResponse(t *testing.T) {
require.Equal(t, cache.ErrNotFound, err)
require.Empty(t, resp)

err = serviceCache.CacheQueryResponse(ctxb, req, defaultQueryResp)
err = serviceCache.CacheQueryResponse(ctxb, req, defaultQueryResp, map[string]string{})
require.NoError(t, err)

resp, err = serviceCache.GetCachedQueryResponse(ctxb, req)
require.NoError(t, err)
require.JSONEq(t, string(defaultQueryResp), string(resp))
require.JSONEq(t, string(defaultQueryResp), string(resp.JsonRpcResponseResult))
}

func mkEVMRPCRequestEnvelope(blockNumber string) *decode.EVMRPCRequestEnvelope {
Expand Down
18 changes: 18 additions & 0 deletions service/cachemdw/caching_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ func (c *ServiceCache) CachingMiddleware(

// if request isn't already cached, request is cacheable and response is present in context - cache the response
if !isCached && cacheable && ok {
headersToCache := getHeadersToCache(w, c.whitelistedHeaders)
if err := c.CacheQueryResponse(
r.Context(),
decodedReq,
typedResponse,
headersToCache,
); err != nil {
c.Logger.Error().Msgf("can't validate and cache response: %v", err)
}
Expand All @@ -64,3 +66,19 @@ func (c *ServiceCache) CachingMiddleware(
next.ServeHTTP(w, r)
}
}

// getHeadersToCache gets header map which has to be cached along with EVM JSON-RPC response
func getHeadersToCache(w http.ResponseWriter, whitelistedHeaders []string) map[string]string {
headersToCache := make(map[string]string, 0)

for _, headerName := range whitelistedHeaders {
headerValue := w.Header().Get(headerName)
if headerValue == "" {
continue
}

headersToCache[headerName] = headerValue
}

return headersToCache
}
2 changes: 2 additions & 0 deletions service/cachemdw/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func TestUnitTestServiceCacheMiddleware(t *testing.T) {
service.DecodedRequestContextKey,
defaultCachePrefixString,
true,
[]string{},
&logger,
)

Expand Down Expand Up @@ -118,6 +119,7 @@ func TestUnitTestServiceCacheMiddleware_CacheIsDisabled(t *testing.T) {
service.DecodedRequestContextKey,
defaultCachePrefixString,
false,
[]string{},
&logger,
)

Expand Down
8 changes: 6 additions & 2 deletions service/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ func createProxyRequestMiddleware(next http.Handler, config config.Config, servi

isCached := cachemdw.IsRequestCached(r.Context())
cachedResponse := r.Context().Value(cachemdw.ResponseContextKey)
typedCachedResponse, ok := cachedResponse.([]byte)
typedCachedResponse, ok := cachedResponse.(*cachemdw.QueryResponse)

// if cache is enabled, request is cached and response is present in context - serve the request from the cache
// otherwise proxy to the actual backend
Expand All @@ -250,7 +250,11 @@ func createProxyRequestMiddleware(next http.Handler, config config.Config, servi

w.Header().Add(cachemdw.CacheHeaderKey, cachemdw.CacheHitHeaderValue)
w.Header().Add("Content-Type", "application/json")
_, err := w.Write(typedCachedResponse)
// add cached headers
for headerName, headerValue := range typedCachedResponse.HeaderMap {
w.Header().Add(headerName, headerValue)
}
_, err := w.Write(typedCachedResponse.JsonRpcResponseResult)
if err != nil {
serviceLogger.Logger.Error().Msg(fmt.Sprintf("can't write cached response: %v", err))
}
Expand Down
1 change: 1 addition & 0 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ func createServiceCache(
DecodedRequestContextKey,
config.CachePrefix,
config.CacheEnabled,
config.WhitelistedHeaders,
logger,
)

Expand Down

0 comments on commit 346a479

Please sign in to comment.