Skip to content

Implement Response Caching for Currency and Institution Endpoints#394

Draft
sundayonah wants to merge 17 commits intomainfrom
implement-response-caching-for-currency-and-institution-endpoints
Draft

Implement Response Caching for Currency and Institution Endpoints#394
sundayonah wants to merge 17 commits intomainfrom
implement-response-caching-for-currency-and-institution-endpoints

Conversation

@sundayonah
Copy link
Collaborator

@sundayonah sundayonah commented Jan 22, 2025

This PR implements Redis-based response caching for frequently accessed static data endpoints to improve API performance and reduce backend load.

Key Changes:

  • Added Redis cache middleware for /currencies, /institutions/:currency_code, and /pubkey endpoints
  • Implemented cache warming on server startup
  • Added cache hit/miss metrics using Prometheus
  • Added proper cache headers (Cache-Control, ETag) and handling
  • Implemented stale-while-revalidate pattern for high availability

Cache Configuration:

  • Currencies: 24 hours
  • Institutions by currency: 24 hours
  • Public key: 365 days

Cache Keys:

  • api:currencies:list
  • api:institutions:{currency_code}
  • api:aggregator:pubkey

Testing:

  • Added comprehensive test suite for cache middleware
  • Includes tests for cache hits, misses, and revalidation
  • Added benchmarks for cache performance

closes #381

Summary by CodeRabbit

  • New Features

    • Redis-backed caching for currencies, institutions, and pubkey endpoints with ETag and Cache-Control headers.
    • Added cached “pubkey” endpoint.
    • Cache warm-up to prepopulate common data.
  • Performance

    • Faster responses and reduced backend load via cache hits and background revalidation.
  • Configuration

    • New environment variables: HOST_DOMAIN, CACHE_VERSION, CURRENCIES_CACHE_DURATION, INSTITUTIONS_CACHE_DURATION, PUBKEY_CACHE_DURATION (with sensible defaults).
  • Observability

    • Cache hit/miss metrics available for monitoring.

@onahprosper
Copy link
Collaborator

This should be a draft pr right @sundayonah

@sundayonah
Copy link
Collaborator Author

This should be a draft pr right @sundayonah

Yes.

@sundayonah sundayonah force-pushed the implement-response-caching-for-currency-and-institution-endpoints branch from 9e07b06 to 3bb24ef Compare January 30, 2025 13:22
@onahprosper onahprosper marked this pull request as draft April 25, 2025 21:28
@sundayonah sundayonah marked this pull request as ready for review May 16, 2025 10:24
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 25, 2025

Walkthrough

Adds Redis-backed response caching with middleware, configuration, and tests. Introduces cache durations and versioning via new env vars/config fields, wires middleware for currencies, institutions, and pubkey routes, adds a cache warming routine at startup, and integrates Prometheus metrics. Updates dependencies to include Redis, mocks, and Prometheus.

Changes

Cohort / File(s) Summary
Environment variables
.env.example
Adds HOST_DOMAIN, CURRENCIES_CACHE_DURATION, INSTITUTIONS_CACHE_DURATION, PUBKEY_CACHE_DURATION, CACHE_VERSION.
Configuration: server
config/server.go
Adds HostDomain, CurrenciesCacheDuration, InstitutionsCacheDuration, PubKeyCacheDuration fields; sets defaults (24h for currencies/institutions, 365h for pubkey); reads from env.
Configuration: redis
config/redis.go
Adds CacheVersion to RedisConfiguration; reads CACHE_VERSION.
Middleware: caching
routers/middleware/caching.go
New Redis-backed CacheService with Prometheus metrics, ETag support, stale-while-revalidate, CacheMiddleware, WarmCache, helpers, and cacheWriter.
Routing and wiring
routers/index.go
Initializes CacheService; applies CacheMiddleware to v1 currencies and institutions routes with configured durations; adds cached v1 pubkey endpoint; startup failure logs fatal.
Tests
routers/middleware/caching_test.go
Adds tests using miniredis and httptest to validate cache hits/misses and WarmCache key population.
Dependencies
go.mod
Adds/updates: redis/go-redis/v9 (v9.2.0), miniredis/v2, redismock/v9, prometheus/client_golang; adds related indirect deps.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant R as Gin Router
  participant M as CacheMiddleware
  participant D as Redis
  participant H as Handler (Upstream)

  rect rgba(230,240,255,0.5)
  Note over R,M: Request flow with cache check
  C->>R: GET /v1/currencies
  R->>M: Invoke middleware
  M->>D: GET key (versioned)
  alt Cache HIT (valid)
    D-->>M: Cached payload + ETag
    M-->>R: Write headers (X-Cache: HIT, Cache-Control, ETag)
    R-->>C: 200 with cached body
  else Cache MISS or STALE
    D-->>M: Not found or expiring
    M-->>R: Proceed, wrap writer
    R->>H: Call handler
    H-->>R: Response body
    R-->>M: After response
    M->>D: SET payload + ETag with TTL
    M-->>R: X-Cache: MISS
    R-->>C: 200 with fresh body
  end
  end

  rect rgba(235,255,235,0.5)
  Note over M,D: Stale-While-Revalidate
  M->>D: Check TTL near expiry
  M-->>M: Background revalidate fetch
  M->>D: Update cached value/ETag
  end
Loading
sequenceDiagram
  autonumber
  participant S as Server Startup
  participant CS as CacheService
  participant D as Redis
  participant API as HTTP Endpoints

  rect rgba(255,245,230,0.5)
  Note over S,CS: WarmCache on init
  S->>CS: NewCacheService(redis config)
  CS->>D: PING
  D-->>CS: PONG
  S->>CS: WarmCache(ctx)
  CS->>API: GET /v1/currencies
  API-->>CS: 200 JSON + ETag
  CS->>D: SET api:currencies:list
  loop for each currency
    CS->>API: GET /v1/institutions/:code
    API-->>CS: 200 JSON + ETag
    CS->>D: SET api:institutions:{code}
  end
  CS->>API: GET /v1/pubkey
  API-->>CS: 200 JSON + ETag
  CS->>D: SET api:aggregator:pubkey
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I cached a moonbeam in Redis tonight,
With keys like carrots I hide out of sight.
Hits hop fast, misses don’t linger—
ETags twirl on a whiskered finger.
Warmed at dawn, the burrow sings—
Swift little bytes on rabbit wings. 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (3 warnings)
Check name Status Explanation Resolution
Linked Issues Check ⚠️ Warning The changes introduce caching middleware with metrics, ETag headers, and the stale-while-revalidate pattern for the specified endpoints and durations, but although a WarmCache function is implemented for startup prefetching, it is not clearly invoked on application initialization, so the automatic cache warming acceptance criterion is not met. Ensure that the WarmCache method is called during server startup (for example in main or the initialization sequence) so that all static data endpoints are fetched and cached automatically as required.
Description Check ⚠️ Warning The pull request description provides a high level overview and testing details but does not follow the repository’s required template headings, omitting the explicit Description, References, Testing, and Checklist sections that the template mandates. Please restructure the description to include the exact template headings (### Description, ### References, ### Testing, and ### Checklist) and ensure each section is populated with the required information such as external links, testing steps, and a completed checklist.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly and accurately describes the primary change by indicating the addition of response caching for the currency and institution endpoints without using vague language or extraneous details.
Out of Scope Changes Check ✅ Passed All modifications, including environment variable additions, configuration updates, dependency changes, router wiring, middleware implementation, and tests, directly support the response caching objectives defined in the linked issue without introducing unrelated features.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch implement-response-caching-for-currency-and-institution-endpoints

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (5)
go.mod (1)

9-9: Fix inconsistent miniredis versions (direct vs indirect).

You require miniredis v2.34.0 directly and v2.35.0 indirectly. Go will pick v2.35.0, which can cause confusion and flaky builds. Align to a single version and drop the indirect entry.

Apply:

-	github.com/alicebob/miniredis/v2 v2.34.0
+	github.com/alicebob/miniredis/v2 v2.35.0

And remove the indirect:

-	github.com/alicebob/miniredis/v2 v2.35.0 // indirect

Also applies to: 39-39

.env.example (1)

13-16: Document units explicitly to prevent misconfiguration.

Add inline comments to clarify:

  • CURRENCIES_CACHE_DURATION, INSTITUTIONS_CACHE_DURATION = hours
  • PUBKEY_CACHE_DURATION = days
  • CACHE_VERSION = key prefix (e.g., v1)

Also applies to: 32-32

routers/middleware/caching.go (3)

31-36: Remove unused CacheConfig.

Dead code adds noise.

Apply:

-type CacheConfig struct {
-	Host     string
-	Port     string
-	Password string
-	DB       int
-}

38-51: Avoid duplicate Prometheus registrations on repeated service init.

NewCacheService registers counters each time it’s called; repeated inits (tests, hot-reload) will panic. Inject a prometheus.Registerer or register once in init.

Example:

func NewCacheService(cfg config.RedisConfiguration, reg prometheus.Registerer) (*CacheService, error) {
  if reg == nil { reg = prometheus.DefaultRegisterer }
  metrics := CacheMetrics{ ... }
  reg.MustRegister(metrics.hits, metrics.misses)
  ...
}

9-11: Replace deprecated ioutil with io.

[Suggest using io.ReadAll.]

Apply:

-  "io/ioutil"
+  "io"
-  body, err := ioutil.ReadAll(resp.Body)
+  body, err := io.ReadAll(resp.Body)
-    body, _ := ioutil.ReadAll(resp.Body)
+    body, _ := io.ReadAll(resp.Body)

Also applies to: 215-218, 250-251

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ffbf5b0 and 3e264ef.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (7)
  • .env.example (2 hunks)
  • config/redis.go (2 hunks)
  • config/server.go (2 hunks)
  • go.mod (3 hunks)
  • routers/index.go (3 hunks)
  • routers/middleware/caching.go (1 hunks)
  • routers/middleware/caching_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
routers/index.go (3)
config/redis.go (1)
  • RedisConfig (19-27)
config/server.go (1)
  • ServerConfig (29-62)
routers/middleware/caching.go (1)
  • NewCacheService (39-63)
routers/middleware/caching_test.go (1)
routers/middleware/caching.go (2)
  • CacheService (26-29)
  • CacheMetrics (20-23)
routers/middleware/caching.go (2)
config/redis.go (2)
  • RedisConfiguration (10-16)
  • RedisConfig (19-27)
config/server.go (1)
  • ServerConfig (29-62)
🪛 dotenv-linter (3.3.0)
.env.example

[warning] 13-13: [UnorderedKey] The HOST_DOMAIN key should go before the JWT_ACCESS_LIFESPAN key

(UnorderedKey)


[warning] 14-14: [UnorderedKey] The CURRENCIES_CACHE_DURATION key should go before the DEBUG key

(UnorderedKey)


[warning] 15-15: [UnorderedKey] The PUBKEY_CACHE_DURATION key should go before the SECRET key

(UnorderedKey)


[warning] 16-16: [UnorderedKey] The INSTITUTIONS_CACHE_DURATION key should go before the JWT_ACCESS_LIFESPAN key

(UnorderedKey)


[warning] 32-32: [UnorderedKey] The CACHE_VERSION key should go before the REDIS_DB key

(UnorderedKey)

🪛 OSV Scanner (2.2.2)
go.mod

[CRITICAL] 11-11: github.com/btcsuite/btcd 0.22.1: Denial of service in message decoding in github.com/btcsuite/btcd

(GO-2022-1098)


[CRITICAL] 11-11: github.com/btcsuite/btcd 0.22.1: Consensus failures in github.com/btcsuite/btcd

(GO-2024-2818)


[CRITICAL] 11-11: github.com/btcsuite/btcd 0.22.1: Consensus failure in github.com/btcsuite/btcd

(GO-2024-3189)


[CRITICAL] 11-11: github.com/btcsuite/btcd 0.22.1: btcd did not correctly re-implement Bitcoin Core's "FindAndDelete()" functionality

(GHSA-27vh-h6mc-q6g8)


[CRITICAL] 11-11: github.com/btcsuite/btcd 0.22.1: btcd mishandles witness size checking

(GHSA-2chg-86hq-7w38)


[CRITICAL] 11-11: github.com/btcsuite/btcd 0.22.1: btcd susceptible to consensus failures

(GHSA-3jgf-r68h-xfqm)


[HIGH] 17-17: github.com/golang-jwt/jwt/v5 5.0.0: Excessive memory allocation during header parsing in github.com/golang-jwt/jwt

(GO-2025-3553)


[HIGH] 17-17: github.com/golang-jwt/jwt/v5 5.0.0: jwt-go allows excessive memory allocation during header parsing

(GHSA-mh63-6h87-95cp)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-and-test
🔇 Additional comments (1)
config/server.go (1)

37-39: Confirm unit semantics for PubKeyCacheDuration (days vs hours).

Currencies/Institutions are hours; PubKey is days. Ensure all consumers multiply PubKey duration by 24h consistently (see WarmCache bug reported separately).

Also applies to: 58-61

Comment on lines +15 to 16
CacheVersion string
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against empty CACHE_VERSION to avoid “:api:*” keys.

If CACHE_VERSION is unset, keys become “:api:*”, which breaks consistency and tests. Default to "v1".

Apply:

 type RedisConfiguration struct {
   Host     string
   Port     string
   Password string
   DB       int
-    CacheVersion string
+  CacheVersion string
 }

 func RedisConfig() RedisConfiguration {
-  return RedisConfiguration{
+  cv := viper.GetString("CACHE_VERSION")
+  if cv == "" {
+    cv = "v1"
+  }
+  return RedisConfiguration{
     Host:     viper.GetString("REDIS_HOST"),
     Port:     viper.GetString("REDIS_PORT"),
     Password: viper.GetString("REDIS_PASSWORD"),
     DB:       viper.GetInt("REDIS_DB"),
-        CacheVersion: viper.GetString("CACHE_VERSION"),
+    CacheVersion: cv,
   }
 }

Also applies to: 25-26

🤖 Prompt for AI Agents
In config/redis.go around lines 15-16 (and also apply the same change at lines
25-26), the CacheVersion field may be empty which yields keys like ":api:*";
guard against an empty CACHE_VERSION by defaulting it to "v1". Update the struct
initialization or constructor to set CacheVersion = "v1" when the env/config
value is empty and ensure any key-building helper uses CacheVersionFallback :=
CacheVersion; if CacheVersionFallback == "" { CacheVersionFallback = "v1" } so
generated keys never start with a leading colon.

github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.7.0 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect
github.com/btcsuite/btcd v0.22.1 // indirect
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Critical upstream vulns present (btcd).

OSV flags multiple critical advisories for github.com/btcsuite/btcd v0.22.1 (indirect). Not introduced by this PR, but it’s a material risk. Consider upgrading or constraining transitive deps pulling btcd, or vendor a patched version.

Run to list advisories and reverse-deps:


🏁 Script executed:

#!/bin/bash
# Show advisories
gh api graphql -f query='
{ securityVulnerabilities(first: 20, ecosystem: GO, package: "github.com/btcsuite/btcd") {
    nodes { advisory { summary severity permalink } vulnerableVersionRange firstPatchedVersion { identifier } }
  }
}
'

# Who pulls btcd in module graph
go mod graph | rg -nP 'btcsuite/btcd@' -C2

Length of output: 6138


Critical upstream vulns present (btcd v0.22.1).
OSV reports GHSA-2chg-86hq-7w38 (CRITICAL), GHSA-27vh-h6mc-q6g8 (HIGH) and GHSA-3jgf-r68h-xfqm (MODERATE). go.mod transitively pulls v0.22.1. Upgrade btcd to ≥0.24.2-beta.rc1 (or ≥0.23.2 to patch the critical issue), or constrain your transitive deps/vendor a patched fork.

🤖 Prompt for AI Agents
In go.mod around line 45, the project pulls github.com/btcsuite/btcd v0.22.1
which has critical/high OSV vulns; update the dependency to a patched release
(bump to >= v0.24.2-beta.rc1 or at minimum >= v0.23.2) by editing go.mod (or
adding a replace directive for transitive deps to force a patched fork), then
run go get github.com/btcsuite/btcd@v0.24.2-beta.rc1 (or @v0.23.2), followed by
go mod tidy (and go mod vendor if you vendor), verify the effective version with
go list -m all | grep btcd, and run CI/tests to ensure nothing breaks.

Comment on lines +23 to +26
cacheService, err := middleware.NewCacheService(conf)
if err != nil {
log.Fatalf("Failed to initialize cache: %v", err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Don’t crash the app if Redis is unavailable; fall back to pass‑through.

Fatal here violates “gracefully handle cache failures by falling back to direct responses”. Provide a no‑op middleware when cache init fails.

Apply:

-	cacheService, err := middleware.NewCacheService(conf)
-	if err != nil {
-		log.Fatalf("Failed to initialize cache: %v", err)
-	}
+	cacheService, err := middleware.NewCacheService(conf)
+	var cacheMW func(time.Duration) gin.HandlerFunc
+	if err != nil {
+		log.Printf("Failed to initialize cache: %v. Proceeding without caching.", err)
+		cacheMW = func(_ time.Duration) gin.HandlerFunc {
+			return func(c *gin.Context) { c.Next() }
+		}
+	} else {
+		cacheMW = cacheService.CacheMiddleware
+	}

And use cacheMW:

-		cacheService.CacheMiddleware(time.Duration(serverConf.CurrenciesCacheDuration)*time.Hour),
+		cacheMW(time.Duration(serverConf.CurrenciesCacheDuration)*time.Hour),
-		cacheService.CacheMiddleware(time.Duration(serverConf.InstitutionsCacheDuration)*time.Hour),
+		cacheMW(time.Duration(serverConf.InstitutionsCacheDuration)*time.Hour),
-	v1.GET("pubkey",
-		cacheService.CacheMiddleware(time.Duration(serverConf.PubKeyCacheDuration)*24*time.Hour),
-		ctrl.GetAggregatorPublicKey,
-	)
+	v1.GET("pubkey",
+		cacheMW(time.Duration(serverConf.PubKeyCacheDuration)*24*time.Hour),
+		ctrl.GetAggregatorPublicKey,
+	)

Also, where is WarmCache invoked on startup? If not yet wired, consider calling it after routes are registered (non-blocking), or from main bootstrap.

Also applies to: 42-55


🏁 Script executed:

#!/bin/bash
set -e

# Locate routers/index.go
echo "Finding routers/index.go path..."
ROUTERS_FILE=$(rg -l "middleware.NewCacheService" -g "*.go")
echo "routers/index.go located at: $ROUTERS_FILE"

echo "Printing first 100 lines of routers/index.go..."
sed -n '1,100p' "$ROUTERS_FILE"

# Find NewCacheService implementation
echo "Searching for NewCacheService definition..."
SERVICE_FILE=$(rg -l "func NewCacheService" -g "*.go")
echo "NewCacheService defined in: $SERVICE_FILE"

echo "Printing the implementation of NewCacheService..."
sed -n '1,200p' "$SERVICE_FILE"

Length of output: 9776


Use no-op cache middleware on Redis failure and invoke WarmCache on startup

  • In routers/index.go (around lines 23–26), replace the log.Fatalf on middleware.NewCacheService error with a cacheMW wrapper that returns func(c *gin.Context){c.Next()} when init fails, then use cacheMW(...) instead of cacheService.CacheMiddleware(...) in your routes.
  • After routes are registered (or in your main bootstrap), call go cacheService.WarmCache(context.Background()) to prime the cache asynchronously.
🤖 Prompt for AI Agents
In routers/index.go around lines 23 to 26, replace the current fatal log on
NewCacheService error with a no-op cache middleware function that simply calls
the next handler in Gin's context. Assign this no-op function to cacheMW if
initialization fails, otherwise assign cacheMW as cacheService.CacheMiddleware.
Then update route registrations to use cacheMW instead of
cacheService.CacheMiddleware directly. Finally, after all routes are registered
or in the main application startup, invoke go cacheService.WarmCache with a
background context to asynchronously warm the cache.

Comment on lines +35 to +41
func TestCacheMiddleware(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mr, client := setupTestRedis()
defer mr.Close()

cacheService := &CacheService{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Set CACHE_VERSION in tests to match key assertions.

Without setting CACHE_VERSION, middleware may write keys prefixed with “:”, but the test expects “v1:*”.

Apply:

 func TestCacheMiddleware(t *testing.T) {
   // Setup
   gin.SetMode(gin.TestMode)
+  viper.Set("CACHE_VERSION", "v1")
   mr, client := setupTestRedis()
   defer mr.Close()

Also applies to: 63-66

🤖 Prompt for AI Agents
In routers/middleware/caching_test.go around lines 35-41 (and also apply same
change at 63-66), the test never sets the CACHE_VERSION env var so produced keys
are prefixed with ":" while assertions expect "v1:*"; set the environment
variable CACHE_VERSION to "v1" at the start of each affected test (use
os.Setenv("CACHE_VERSION", "v1") and defer restoring/unsetting it) before
creating the cache/middleware client so keys are generated with the "v1:" prefix
and assertions will match.

Comment on lines +65 to +78
func generateCacheKey(c *gin.Context) string {
conf := config.RedisConfig()
path := c.Request.URL.Path
switch {
case path == "/v1/currencies":
return fmt.Sprintf("%s:api:currencies:list", conf.CacheVersion)
case path == "/v1/pubkey":
return fmt.Sprintf("%s:api:aggregator:pubkey", conf.CacheVersion)
case len(c.Param("currency_code")) > 0:
return fmt.Sprintf("%s:api:institutions:%s", conf.CacheVersion, c.Param("currency_code"))
default:
return fmt.Sprintf("%s:api:%s", conf.CacheVersion, path)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Key prefix may be empty; add fallback to “v1”.

Prevents “:api:*” keys and aligns with WarmCache.

Apply:

 func generateCacheKey(c *gin.Context) string {
-	conf := config.RedisConfig()
+	conf := config.RedisConfig()
+	prefix := conf.CacheVersion
+	if prefix == "" {
+		prefix = "v1"
+	}
 	path := c.Request.URL.Path
 	switch {
 	case path == "/v1/currencies":
-		return fmt.Sprintf("%s:api:currencies:list", conf.CacheVersion)
+		return fmt.Sprintf("%s:api:currencies:list", prefix)
 	case path == "/v1/pubkey":
-		return fmt.Sprintf("%s:api:aggregator:pubkey", conf.CacheVersion)
+		return fmt.Sprintf("%s:api:aggregator:pubkey", prefix)
 	case len(c.Param("currency_code")) > 0:
-		return fmt.Sprintf("%s:api:institutions:%s", conf.CacheVersion, c.Param("currency_code"))
+		return fmt.Sprintf("%s:api:institutions:%s", prefix, c.Param("currency_code"))
 	default:
-		return fmt.Sprintf("%s:api:%s", conf.CacheVersion, path)
+		return fmt.Sprintf("%s:api:%s", prefix, path)
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func generateCacheKey(c *gin.Context) string {
conf := config.RedisConfig()
path := c.Request.URL.Path
switch {
case path == "/v1/currencies":
return fmt.Sprintf("%s:api:currencies:list", conf.CacheVersion)
case path == "/v1/pubkey":
return fmt.Sprintf("%s:api:aggregator:pubkey", conf.CacheVersion)
case len(c.Param("currency_code")) > 0:
return fmt.Sprintf("%s:api:institutions:%s", conf.CacheVersion, c.Param("currency_code"))
default:
return fmt.Sprintf("%s:api:%s", conf.CacheVersion, path)
}
}
func generateCacheKey(c *gin.Context) string {
conf := config.RedisConfig()
prefix := conf.CacheVersion
if prefix == "" {
prefix = "v1"
}
path := c.Request.URL.Path
switch {
case path == "/v1/currencies":
return fmt.Sprintf("%s:api:currencies:list", prefix)
case path == "/v1/pubkey":
return fmt.Sprintf("%s:api:aggregator:pubkey", prefix)
case len(c.Param("currency_code")) > 0:
return fmt.Sprintf("%s:api:institutions:%s", prefix, c.Param("currency_code"))
default:
return fmt.Sprintf("%s:api:%s", prefix, path)
}
}
🤖 Prompt for AI Agents
In routers/middleware/caching.go around lines 65 to 78, the cache key prefix
uses conf.CacheVersion which may be empty, producing keys like ":api:...";
ensure you set a fallback when CacheVersion is empty (e.g., version :=
conf.CacheVersion; if version == "" { version = "v1" }) and use that version
variable in all fmt.Sprintf calls so keys are always prefixed with a non-empty
version (aligns with WarmCache).

Comment on lines +85 to +107
func (s *CacheService) CacheMiddleware(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
key := generateCacheKey(c)
ctx := context.Background()

// Check ETag
if etag := c.GetHeader("If-None-Match"); etag != "" {
if storedETag, _ := s.client.Get(ctx, key+":etag").Result(); etag == storedETag {
c.Status(http.StatusNotModified)
return
}
}

// Try to get from cache
val, err := s.client.Get(ctx, key).Result()
if err == nil {
s.metrics.hits.Inc()
etag, _ := s.client.Get(ctx, key+":etag").Result()

c.Header("X-Cache", "HIT")
c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds())))
c.Header("ETag", etag)
c.String(200, val)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return correct headers for 304 and serve cached JSON with proper Content‑Type.

  • 304 should include ETag and Cache-Control.
  • Using c.String sets text/plain; serve JSON as application/json.

Apply:

 func (s *CacheService) CacheMiddleware(duration time.Duration) gin.HandlerFunc {
   return func(c *gin.Context) {
     key := generateCacheKey(c)
-    ctx := context.Background()
+    ctx := c.Request.Context()

     // Check ETag
-    if etag := c.GetHeader("If-None-Match"); etag != "" {
-      if storedETag, _ := s.client.Get(ctx, key+":etag").Result(); etag == storedETag {
-        c.Status(http.StatusNotModified)
-        return
-      }
-    }
+    if inm := c.GetHeader("If-None-Match"); inm != "" {
+      if storedETag, _ := s.client.Get(ctx, key+":etag").Result(); inm == storedETag {
+        c.Header("ETag", storedETag)
+        c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds())))
+        c.Status(http.StatusNotModified)
+        return
+      }
+    }

     // Try to get from cache
     val, err := s.client.Get(ctx, key).Result()
     if err == nil {
       s.metrics.hits.Inc()
       etag, _ := s.client.Get(ctx, key+":etag").Result()

       c.Header("X-Cache", "HIT")
       c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds())))
-      c.Header("ETag", etag)
-      c.String(200, val)
+      if etag != "" {
+        c.Header("ETag", etag)
+      }
+      c.Data(http.StatusOK, "application/json", []byte(val))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *CacheService) CacheMiddleware(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
key := generateCacheKey(c)
ctx := context.Background()
// Check ETag
if etag := c.GetHeader("If-None-Match"); etag != "" {
if storedETag, _ := s.client.Get(ctx, key+":etag").Result(); etag == storedETag {
c.Status(http.StatusNotModified)
return
}
}
// Try to get from cache
val, err := s.client.Get(ctx, key).Result()
if err == nil {
s.metrics.hits.Inc()
etag, _ := s.client.Get(ctx, key+":etag").Result()
c.Header("X-Cache", "HIT")
c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds())))
c.Header("ETag", etag)
c.String(200, val)
func (s *CacheService) CacheMiddleware(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
key := generateCacheKey(c)
ctx := c.Request.Context()
// Check ETag
if inm := c.GetHeader("If-None-Match"); inm != "" {
if storedETag, _ := s.client.Get(ctx, key+":etag").Result(); inm == storedETag {
c.Header("ETag", storedETag)
c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds())))
c.Status(http.StatusNotModified)
return
}
}
// Try to get from cache
val, err := s.client.Get(ctx, key).Result()
if err == nil {
s.metrics.hits.Inc()
etag, _ := s.client.Get(ctx, key+":etag").Result()
c.Header("X-Cache", "HIT")
c.Header("Cache-Control", fmt.Sprintf("max-age=%d, stale-while-revalidate=60", int(duration.Seconds())))
if etag != "" {
c.Header("ETag", etag)
}
c.Data(http.StatusOK, "application/json", []byte(val))
🤖 Prompt for AI Agents
In routers/middleware/caching.go around lines 85-107, the middleware currently
returns 304 without ETag and Cache-Control headers and serves cached JSON with
c.String which sets text/plain; change the 304 branch to set the ETag and
Cache-Control headers (use the same max-age from duration and
stale-while-revalidate) before calling c.Status(http.StatusNotModified) and
return, and when serving a cache hit set Content-Type to application/json (e.g.,
set c.Header("Content-Type","application/json") or use c.Data with content-type
application/json) instead of c.String so cached JSON responses are returned with
the correct content type.

Comment on lines +109 to +113
// Background revalidation if approaching expiry
if ttl, _ := s.client.TTL(ctx, key).Result(); ttl < time.Minute {
go s.revalidateCache(c.Copy(), key, duration)
}
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Stale‑while‑revalidate is broken: invalid URL used for background fetch.

revalidateCache builds a request with only the path; http.Client requires a full URL. Use HOST_DOMAIN and add a timeout.

Apply:

-      if ttl, _ := s.client.TTL(ctx, key).Result(); ttl < time.Minute {
-        go s.revalidateCache(c.Copy(), key, duration)
-      }
+      if ttl, _ := s.client.TTL(ctx, key).Result(); ttl > 0 && ttl < time.Minute {
+        go s.revalidateCache(c.Copy(), key, duration)
+      }
-func (s *CacheService) revalidateCache(c *gin.Context, key string, duration time.Duration) {
-  ctx := context.Background()
-  req, _ := http.NewRequest(c.Request.Method, c.Request.URL.String(), nil)
-  resp, err := http.DefaultClient.Do(req)
+func (s *CacheService) revalidateCache(c *gin.Context, key string, duration time.Duration) {
+  ctx := context.Background()
+  conf := config.ServerConfig()
+  if conf.HostDomain == "" {
+    return
+  }
+  url := fmt.Sprintf("%s%s", conf.HostDomain, c.Request.URL.RequestURI())
+  client := &http.Client{Timeout: 10 * time.Second}
+  req, _ := http.NewRequestWithContext(ctx, c.Request.Method, url, nil)
+  resp, err := client.Do(req)
   if err != nil {
     return
   }
   defer resp.Body.Close()

   if resp.StatusCode == http.StatusOK {
-    body, _ := ioutil.ReadAll(resp.Body)
+    body, _ := io.ReadAll(resp.Body)
     etag := generateETag(body)
-    s.client.Set(ctx, key, string(body), duration)
-    s.client.Set(ctx, key+":etag", etag, duration)
+    _ = s.client.Set(ctx, key, string(body), duration).Err()
+    _ = s.client.Set(ctx, key+":etag", etag, duration).Err()
   }
 }

Also applies to: 240-255

🤖 Prompt for AI Agents
In routers/middleware/caching.go around lines 109-113 (and likewise apply fix to
240-255): revalidateCache is issuing an HTTP request using only the path which
yields an invalid URL for http.Client; update the code to build a full absolute
URL by prefixing the path with the configured HOST_DOMAIN (including scheme,
e.g. https://HOST_DOMAIN) and ensure the request has the proper context (use the
copied gin.Context's request context or attach ctx via req.WithContext); also
give the background fetch a bounded timeout by using an http.Client with a
Timeout or creating a context with timeout for the request so the goroutine
cannot hang indefinitely.

Comment on lines +176 to +199
// Cache currencies
currenciesKey := fmt.Sprintf("v1:api:currencies:list")
currenciesData, err := json.Marshal(currencies)
if err != nil {
return fmt.Errorf("failed to marshal currencies: %v", err)
}
if err := s.client.Set(ctx, currenciesKey, string(currenciesData), time.Duration(conf.CurrenciesCacheDuration)*time.Hour).Err(); err != nil {
return fmt.Errorf("failed to cache currencies: %v", err)
}

// Cache pubkey
pubkeyURL := fmt.Sprintf("%s/v1/pubkey", baseURL)
if err := s.cacheEndpoint(ctx, pubkeyURL, "v1:api:aggregator:pubkey", time.Duration(conf.PubKeyCacheDuration)*time.Hour); err != nil {
return fmt.Errorf("failed to cache pubkey: %v", err)
}

// Cache institutions for each currency
for _, currency := range currencies {
institutionsURL := fmt.Sprintf("%s/v1/institutions/%s", baseURL, currency)
key := fmt.Sprintf("v1:api:institutions:%s", currency)
if err := s.cacheEndpoint(ctx, institutionsURL, key, time.Duration(conf.InstitutionsCacheDuration)*time.Hour); err != nil {
return fmt.Errorf("failed to cache institutions for %s: %v", currency, err)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

WarmCache uses hardcoded “v1:” and wrong PubKey TTL units.

  • Keys ignore CACHE_VERSION.
  • PubKey TTL should be days (×24h), not hours.

Apply:

-  // Cache currencies
-  currenciesKey := fmt.Sprintf("v1:api:currencies:list")
+  // Cache currencies
+  prefix := config.RedisConfig().CacheVersion
+  if prefix == "" {
+    prefix = "v1"
+  }
+  currenciesKey := fmt.Sprintf("%s:api:currencies:list", prefix)
   currenciesData, err := json.Marshal(currencies)
   ...
-  // Cache pubkey
+  // Cache pubkey
   pubkeyURL := fmt.Sprintf("%s/v1/pubkey", baseURL)
-  if err := s.cacheEndpoint(ctx, pubkeyURL, "v1:api:aggregator:pubkey", time.Duration(conf.PubKeyCacheDuration)*time.Hour); err != nil {
+  if err := s.cacheEndpoint(ctx, pubkeyURL, fmt.Sprintf("%s:api:aggregator:pubkey", prefix), time.Duration(conf.PubKeyCacheDuration)*24*time.Hour); err != nil {
     return fmt.Errorf("failed to cache pubkey: %v", err)
   }
 ...
-    key := fmt.Sprintf("v1:api:institutions:%s", currency)
+    key := fmt.Sprintf("%s:api:institutions:%s", prefix, currency)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Cache currencies
currenciesKey := fmt.Sprintf("v1:api:currencies:list")
currenciesData, err := json.Marshal(currencies)
if err != nil {
return fmt.Errorf("failed to marshal currencies: %v", err)
}
if err := s.client.Set(ctx, currenciesKey, string(currenciesData), time.Duration(conf.CurrenciesCacheDuration)*time.Hour).Err(); err != nil {
return fmt.Errorf("failed to cache currencies: %v", err)
}
// Cache pubkey
pubkeyURL := fmt.Sprintf("%s/v1/pubkey", baseURL)
if err := s.cacheEndpoint(ctx, pubkeyURL, "v1:api:aggregator:pubkey", time.Duration(conf.PubKeyCacheDuration)*time.Hour); err != nil {
return fmt.Errorf("failed to cache pubkey: %v", err)
}
// Cache institutions for each currency
for _, currency := range currencies {
institutionsURL := fmt.Sprintf("%s/v1/institutions/%s", baseURL, currency)
key := fmt.Sprintf("v1:api:institutions:%s", currency)
if err := s.cacheEndpoint(ctx, institutionsURL, key, time.Duration(conf.InstitutionsCacheDuration)*time.Hour); err != nil {
return fmt.Errorf("failed to cache institutions for %s: %v", currency, err)
}
}
// Cache currencies
prefix := config.RedisConfig().CacheVersion
if prefix == "" {
prefix = "v1"
}
currenciesKey := fmt.Sprintf("%s:api:currencies:list", prefix)
currenciesData, err := json.Marshal(currencies)
if err != nil {
return fmt.Errorf("failed to marshal currencies: %v", err)
}
if err := s.client.Set(ctx, currenciesKey, string(currenciesData), time.Duration(conf.CurrenciesCacheDuration)*time.Hour).Err(); err != nil {
return fmt.Errorf("failed to cache currencies: %v", err)
}
// Cache pubkey
pubkeyURL := fmt.Sprintf("%s/v1/pubkey", baseURL)
if err := s.cacheEndpoint(
ctx,
pubkeyURL,
fmt.Sprintf("%s:api:aggregator:pubkey", prefix),
time.Duration(conf.PubKeyCacheDuration)*24*time.Hour,
); err != nil {
return fmt.Errorf("failed to cache pubkey: %v", err)
}
// Cache institutions for each currency
for _, currency := range currencies {
institutionsURL := fmt.Sprintf("%s/v1/institutions/%s", baseURL, currency)
key := fmt.Sprintf("%s:api:institutions:%s", prefix, currency)
if err := s.cacheEndpoint(
ctx,
institutionsURL,
key,
time.Duration(conf.InstitutionsCacheDuration)*time.Hour,
); err != nil {
return fmt.Errorf("failed to cache institutions for %s: %v", currency, err)
}
}
🤖 Prompt for AI Agents
In routers/middleware/caching.go around lines 176 to 199, keys are built with a
hardcoded "v1:" prefix and PubKey TTL uses hours not days; replace the hardcoded
prefix with the shared CACHE_VERSION (e.g. fmt.Sprintf("%s:api:...",
CACHE_VERSION)) for currencies, pubkey and institutions keys so all cache keys
honor the version, and change the PubKey TTL calculation to use days
(time.Duration(conf.PubKeyCacheDuration)*24*time.Hour) instead of hours; update
the three key constructions (currenciesKey, pubkey key, and institutions key)
and the pubkey cache set call to use those corrected values.

Comment on lines +204 to +218
func (s *CacheService) cacheEndpoint(ctx context.Context, url, key string, duration time.Duration) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to fetch from %s: %v", url, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("received non-200 status code (%d) from %s", resp.StatusCode, url)
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body from %s: %v", url, err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use an HTTP client with timeout and context in cacheEndpoint.

http.Get has no timeout; can hang startup.

Apply:

-func (s *CacheService) cacheEndpoint(ctx context.Context, url, key string, duration time.Duration) error {
-  resp, err := http.Get(url)
+func (s *CacheService) cacheEndpoint(ctx context.Context, url, key string, duration time.Duration) error {
+  client := &http.Client{Timeout: 10 * time.Second}
+  req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+  if err != nil {
+    return fmt.Errorf("failed to build request for %s: %v", url, err)
+  }
+  resp, err := client.Do(req)
   if err != nil {
     return fmt.Errorf("failed to fetch from %s: %v", url, err)
   }
   defer resp.Body.Close()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *CacheService) cacheEndpoint(ctx context.Context, url, key string, duration time.Duration) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to fetch from %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("received non-200 status code (%d) from %s", resp.StatusCode, url)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body from %s: %v", url, err)
}
func (s *CacheService) cacheEndpoint(ctx context.Context, url, key string, duration time.Duration) error {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to build request for %s: %v", url, err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch from %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("received non-200 status code (%d) from %s", resp.StatusCode, url)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body from %s: %v", url, err)
}
// ... rest of function unchanged ...
return nil
}
🤖 Prompt for AI Agents
In routers/middleware/caching.go around lines 204 to 218, replace the blocking
http.Get call with an HTTP request that uses the provided ctx and a client with
a timeout: create an http.Request using http.NewRequestWithContext(ctx, "GET",
url, nil), create an http.Client with a sensible timeout (or derive a timeout
from ctx.Deadline if present) and use client.Do(req) instead of http.Get, then
handle errors, response status, body read and defer resp.Body.Close() as before;
this ensures the fetch respects context cancellation and won't hang
indefinitely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Response Caching for Currency and Institution Endpoints

3 participants

Comments