From f0dcb1151d8d8b07a25f5f8562773d47c8385387 Mon Sep 17 00:00:00 2001 From: Ebenezer199914 Date: Mon, 29 Jun 2026 03:45:15 +0000 Subject: [PATCH] feat: add per-endpoint cache TTL config via env vars (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/config/cacheConfig.js — centralised TTL config module that reads CACHE_TTL_NETWORK_STATUS_MS, CACHE_TTL_FEE_ESTIMATE_MS, CACHE_TTL_BASE_FEE_MS, CACHE_TTL_VALIDATORS_MS, CACHE_TTL_ASSET_MS, CACHE_TTL_ASSET_PRICE_MS from env (all in ms, converted to seconds). Falls back to per-endpoint sensible defaults; respects legacy CACHE_TTL_MS as a global fallback so existing deployments are unaffected. - Update networkStatus.js, feeEstimate.js, network.js, asset.js to import cacheTTL from cacheConfig and replace all hardcoded TTL values. - Update .env.example with all six new per-endpoint variables alongside the existing CACHE_TTL_MS global fallback, with inline comments. - Add tests/cacheConfig.test.js: 20 tests covering default values, per-endpoint overrides, global fallback precedence, and edge cases (NaN, zero, negative, large values). --- .env.example | 16 +++- src/config/cacheConfig.js | 66 ++++++++++++++ src/routes/asset.js | 9 +- src/routes/feeEstimate.js | 9 +- src/routes/network.js | 8 +- src/routes/networkStatus.js | 7 +- tests/cacheConfig.test.js | 177 ++++++++++++++++++++++++++++++++++++ 7 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 src/config/cacheConfig.js create mode 100644 tests/cacheConfig.test.js diff --git a/.env.example b/.env.example index 602b074..67994a1 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,19 @@ STELLAR_NETWORK=testnet PORT=3000 # Cache -# TTL for in-memory caches (milliseconds) +# Global fallback TTL for in-memory caches (milliseconds). +# Used as the default when a per-endpoint variable is not set. CACHE_TTL_MS=5000 +# Per-endpoint cache TTL overrides (all in milliseconds). +# When set, these take precedence over CACHE_TTL_MS for their respective endpoint. +CACHE_TTL_NETWORK_STATUS_MS=5000 +CACHE_TTL_FEE_ESTIMATE_MS=5000 +CACHE_TTL_BASE_FEE_MS=5000 +CACHE_TTL_VALIDATORS_MS=300000 +CACHE_TTL_ASSET_MS=30000 +CACHE_TTL_ASSET_PRICE_MS=5000 + # API Key authentication # If set to "true", the API key middleware will require a valid API key # for protected routes. @@ -21,6 +31,4 @@ REQUIRE_API_KEY=false # Whitespace around keys is trimmed by the middleware. API_KEYS=validkey1,validkey2 -# Cache TTL for asset price endpoint (milliseconds) -# Default: 5000 (5 seconds) -CACHE_TTL_ASSET_PRICE_MS=5000 + diff --git a/src/config/cacheConfig.js b/src/config/cacheConfig.js new file mode 100644 index 0000000..25663f4 --- /dev/null +++ b/src/config/cacheConfig.js @@ -0,0 +1,66 @@ +/** + * Per-endpoint cache TTL configuration. + * + * Each value is read from a dedicated environment variable (in milliseconds) + * and converted to seconds for use with node-cache. A sensible default is + * provided for every endpoint so the server works out-of-the-box without any + * env configuration. + * + * Environment variables (all in milliseconds): + * CACHE_TTL_NETWORK_STATUS_MS — /network-status (default: 5 000 ms) + * CACHE_TTL_FEE_ESTIMATE_MS — /fee-estimate & surge-status (default: 5 000 ms) + * CACHE_TTL_BASE_FEE_MS — /network/base-fee (default: 5 000 ms) + * CACHE_TTL_VALIDATORS_MS — /network/validators (default: 300 000 ms) + * CACHE_TTL_ASSET_MS — /asset/:code/:issuer (default: 30 000 ms) + * CACHE_TTL_ASSET_PRICE_MS — /asset price endpoint (default: 5 000 ms) + * + * The legacy CACHE_TTL_MS variable is still respected as a global fallback so + * existing deployments are not broken. + */ + +function msToSeconds(ms, defaultMs) { + const parsed = parseInt(ms, 10); + return (Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMs) / 1000; +} + +const globalFallbackMs = parseInt(process.env.CACHE_TTL_MS, 10) || 5000; + +const cacheTTL = { + /** /network-status — one ledger close interval */ + networkStatus: msToSeconds( + process.env.CACHE_TTL_NETWORK_STATUS_MS, + globalFallbackMs + ), + + /** /fee-estimate and /fee-estimate/surge-status */ + feeEstimate: msToSeconds( + process.env.CACHE_TTL_FEE_ESTIMATE_MS, + globalFallbackMs + ), + + /** /network/base-fee */ + baseFee: msToSeconds( + process.env.CACHE_TTL_BASE_FEE_MS, + globalFallbackMs + ), + + /** /network/validators — changes rarely, longer TTL by default */ + validators: msToSeconds( + process.env.CACHE_TTL_VALIDATORS_MS, + 300000 + ), + + /** /asset/:code/:issuer */ + asset: msToSeconds( + process.env.CACHE_TTL_ASSET_MS, + 30000 + ), + + /** asset price endpoint */ + assetPrice: msToSeconds( + process.env.CACHE_TTL_ASSET_PRICE_MS, + globalFallbackMs + ), +}; + +module.exports = cacheTTL; diff --git a/src/routes/asset.js b/src/routes/asset.js index 5ea9631..33d2623 100644 --- a/src/routes/asset.js +++ b/src/routes/asset.js @@ -12,6 +12,7 @@ const { validateAccountId, validateAssetCode, validateAsset, validateLimit } = r const { parsePaginationParams } = require("../utils/pagination"); const { makeAssetNotFoundError } = require("../utils/errors"); const cacheService = require("../services/cache"); +const cacheTTL = require("../config/cacheConfig"); router.use(normalizeAssetCode); @@ -161,8 +162,8 @@ router.get("/:code/:issuer", async (req, res, next) => { issuer: issuerInfo, }; - // Cache the response with 30s TTL - cacheService.set(cacheKey, data, 30); + // Cache the response + cacheService.set(cacheKey, data, cacheTTL.asset); res.set("X-Cache", "MISS"); return success(res, data); @@ -466,7 +467,6 @@ router.get("/:code/:issuer/verify", async (req, res, next) => { } }); -const CACHE_TTL_ASSET_PRICE = parseInt(process.env.CACHE_TTL_ASSET_PRICE_MS || "5000", 10); /** * GET /asset/:code/:issuer/price @@ -520,8 +520,7 @@ router.get("/:code/:issuer/price", async (req, res, next) => { quoteAsset: "XLM", }; - const ttlSeconds = CACHE_TTL_ASSET_PRICE / 1000; - cacheService.set(cacheKey, data, ttlSeconds); + cacheService.set(cacheKey, data, cacheTTL.assetPrice); res.set("X-Cache", "MISS"); return success(res, data); diff --git a/src/routes/feeEstimate.js b/src/routes/feeEstimate.js index 2961356..b195b77 100644 --- a/src/routes/feeEstimate.js +++ b/src/routes/feeEstimate.js @@ -3,13 +3,10 @@ const router = express.Router(); const { server } = require("../config/stellar"); const { success } = require("../utils/response"); const cacheService = require("../services/cache"); +const cacheTTL = require("../config/cacheConfig"); const STROOPS_PER_XLM = 10_000_000; -const DEFAULT_CACHE_TTL_SECONDS = 5; -const CACHE_TTL = - parseInt(process.env.CACHE_TTL_MS, 10) / 1000 || DEFAULT_CACHE_TTL_SECONDS; - const LEDGER_HISTORY_LIMIT = 5; const STROOP_DECIMALS = 7; @@ -148,7 +145,7 @@ router.get("/", async (req, res, next) => { }; // Cache the response - cacheService.set(cacheKey, data, CACHE_TTL); + cacheService.set(cacheKey, data, cacheTTL.feeEstimate); res.set("X-Cache", "MISS"); return success(res, data); @@ -263,7 +260,7 @@ router.get("/surge-status", async (req, res, next) => { }; // Cache the response (surge status can be cached briefly since it's analyzed data) - cacheService.set(cacheKey, data, CACHE_TTL); + cacheService.set(cacheKey, data, cacheTTL.feeEstimate); res.set("X-Cache", "MISS"); return success(res, data); diff --git a/src/routes/network.js b/src/routes/network.js index 134b33c..19c9691 100644 --- a/src/routes/network.js +++ b/src/routes/network.js @@ -3,9 +3,7 @@ const router = express.Router(); const { server, horizonUrl } = require("../config/stellar"); const { success } = require("../utils/response"); const cacheService = require("../services/cache"); - -const VALIDATORS_CACHE_TTL = 300; // 5 minutes -const BASE_FEE_CACHE_TTL = 5; // 5 seconds — one ledger close interval +const cacheTTL = require("../config/cacheConfig"); /** * GET /network/base-fee @@ -52,7 +50,7 @@ router.get("/base-fee", async (req, res, next) => { note: "Base fee is in stroops. 1 XLM = 10,000,000 stroops. Cached for 5 seconds.", }; - cacheService.set(cacheKey, data, BASE_FEE_CACHE_TTL); + cacheService.set(cacheKey, data, cacheTTL.baseFee); res.set("X-Cache", "MISS"); return success(res, data); @@ -133,7 +131,7 @@ router.get("/validators", async (req, res, next) => { ungrouped, }; - cacheService.set(cacheKey, data, VALIDATORS_CACHE_TTL); + cacheService.set(cacheKey, data, cacheTTL.validators); res.set("X-Cache", "MISS"); return success(res, data); diff --git a/src/routes/networkStatus.js b/src/routes/networkStatus.js index f58fad9..330b809 100644 --- a/src/routes/networkStatus.js +++ b/src/routes/networkStatus.js @@ -3,8 +3,7 @@ const router = express.Router(); const { server, horizonUrl, NETWORK } = require("../config/stellar"); const { success, toISOTimestamp } = require("../utils/response"); const cacheService = require("../services/cache"); - -const CACHE_TTL = 5; // seconds +const cacheTTL = require("../config/cacheConfig"); /** * GET /network-status @@ -57,8 +56,8 @@ router.get("/", async (req, res, next) => { }, }; - // Cache the response with 5s TTL - cacheService.set(cacheKey, data, 5); + // Cache the response + cacheService.set(cacheKey, data, cacheTTL.networkStatus); res.set("X-Cache", "MISS"); return success(res, data); diff --git a/tests/cacheConfig.test.js b/tests/cacheConfig.test.js new file mode 100644 index 0000000..de2604b --- /dev/null +++ b/tests/cacheConfig.test.js @@ -0,0 +1,177 @@ +"use strict"; + +/** + * tests/cacheConfig.test.js + * + * Unit tests for src/config/cacheConfig.js + * + * The module reads environment variables at require-time, so each test group + * sets the relevant env vars, flushes the module from the require cache, and + * re-imports it to pick up the fresh values. + */ + +function loadCacheConfig() { + // Clear the module from the require cache so env changes take effect. + jest.resetModules(); + return require("../src/config/cacheConfig"); +} + +describe("cacheConfig — default values", () => { + beforeEach(() => { + // Remove all cache-TTL-related env vars so defaults are used. + delete process.env.CACHE_TTL_MS; + delete process.env.CACHE_TTL_NETWORK_STATUS_MS; + delete process.env.CACHE_TTL_FEE_ESTIMATE_MS; + delete process.env.CACHE_TTL_BASE_FEE_MS; + delete process.env.CACHE_TTL_VALIDATORS_MS; + delete process.env.CACHE_TTL_ASSET_MS; + delete process.env.CACHE_TTL_ASSET_PRICE_MS; + }); + + it("networkStatus defaults to 5 seconds", () => { + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(5); + }); + + it("feeEstimate defaults to 5 seconds", () => { + const cfg = loadCacheConfig(); + expect(cfg.feeEstimate).toBe(5); + }); + + it("baseFee defaults to 5 seconds", () => { + const cfg = loadCacheConfig(); + expect(cfg.baseFee).toBe(5); + }); + + it("validators defaults to 300 seconds (5 minutes)", () => { + const cfg = loadCacheConfig(); + expect(cfg.validators).toBe(300); + }); + + it("asset defaults to 30 seconds", () => { + const cfg = loadCacheConfig(); + expect(cfg.asset).toBe(30); + }); + + it("assetPrice defaults to 5 seconds", () => { + const cfg = loadCacheConfig(); + expect(cfg.assetPrice).toBe(5); + }); +}); + +describe("cacheConfig — per-endpoint overrides", () => { + afterEach(() => { + delete process.env.CACHE_TTL_NETWORK_STATUS_MS; + delete process.env.CACHE_TTL_FEE_ESTIMATE_MS; + delete process.env.CACHE_TTL_BASE_FEE_MS; + delete process.env.CACHE_TTL_VALIDATORS_MS; + delete process.env.CACHE_TTL_ASSET_MS; + delete process.env.CACHE_TTL_ASSET_PRICE_MS; + }); + + it("CACHE_TTL_NETWORK_STATUS_MS overrides networkStatus TTL", () => { + process.env.CACHE_TTL_NETWORK_STATUS_MS = "10000"; + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(10); + }); + + it("CACHE_TTL_FEE_ESTIMATE_MS overrides feeEstimate TTL", () => { + process.env.CACHE_TTL_FEE_ESTIMATE_MS = "20000"; + const cfg = loadCacheConfig(); + expect(cfg.feeEstimate).toBe(20); + }); + + it("CACHE_TTL_BASE_FEE_MS overrides baseFee TTL", () => { + process.env.CACHE_TTL_BASE_FEE_MS = "3000"; + const cfg = loadCacheConfig(); + expect(cfg.baseFee).toBe(3); + }); + + it("CACHE_TTL_VALIDATORS_MS overrides validators TTL", () => { + process.env.CACHE_TTL_VALIDATORS_MS = "60000"; + const cfg = loadCacheConfig(); + expect(cfg.validators).toBe(60); + }); + + it("CACHE_TTL_ASSET_MS overrides asset TTL", () => { + process.env.CACHE_TTL_ASSET_MS = "15000"; + const cfg = loadCacheConfig(); + expect(cfg.asset).toBe(15); + }); + + it("CACHE_TTL_ASSET_PRICE_MS overrides assetPrice TTL", () => { + process.env.CACHE_TTL_ASSET_PRICE_MS = "8000"; + const cfg = loadCacheConfig(); + expect(cfg.assetPrice).toBe(8); + }); +}); + +describe("cacheConfig — global CACHE_TTL_MS fallback", () => { + afterEach(() => { + delete process.env.CACHE_TTL_MS; + delete process.env.CACHE_TTL_NETWORK_STATUS_MS; + delete process.env.CACHE_TTL_FEE_ESTIMATE_MS; + delete process.env.CACHE_TTL_BASE_FEE_MS; + delete process.env.CACHE_TTL_ASSET_PRICE_MS; + }); + + it("CACHE_TTL_MS is used as fallback for networkStatus when per-endpoint var is absent", () => { + process.env.CACHE_TTL_MS = "12000"; + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(12); + }); + + it("CACHE_TTL_MS is used as fallback for feeEstimate when per-endpoint var is absent", () => { + process.env.CACHE_TTL_MS = "12000"; + const cfg = loadCacheConfig(); + expect(cfg.feeEstimate).toBe(12); + }); + + it("per-endpoint var takes precedence over CACHE_TTL_MS", () => { + process.env.CACHE_TTL_MS = "12000"; + process.env.CACHE_TTL_NETWORK_STATUS_MS = "2000"; + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(2); + }); + + it("CACHE_TTL_MS does NOT override the validators hard-coded default (300 s)", () => { + // validators has its own hard-coded default of 300 000 ms; CACHE_TTL_MS + // is only used for endpoints whose default is 5 000 ms. + process.env.CACHE_TTL_MS = "12000"; + const cfg = loadCacheConfig(); + // validators still falls back to its own 300 s default when + // CACHE_TTL_VALIDATORS_MS is absent + expect(cfg.validators).toBe(300); + }); +}); + +describe("cacheConfig — invalid / edge-case values", () => { + afterEach(() => { + delete process.env.CACHE_TTL_NETWORK_STATUS_MS; + delete process.env.CACHE_TTL_MS; + }); + + it("falls back to default when env var is NaN", () => { + process.env.CACHE_TTL_NETWORK_STATUS_MS = "not-a-number"; + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(5); + }); + + it("falls back to default when env var is zero", () => { + process.env.CACHE_TTL_NETWORK_STATUS_MS = "0"; + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(5); + }); + + it("falls back to default when env var is negative", () => { + process.env.CACHE_TTL_NETWORK_STATUS_MS = "-1000"; + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(5); + }); + + it("returns correct seconds for a large valid value", () => { + process.env.CACHE_TTL_NETWORK_STATUS_MS = "600000"; // 10 minutes + const cfg = loadCacheConfig(); + expect(cfg.networkStatus).toBe(600); + }); +});