Skip to content

Add ETag and conditional-request support with cache policies per endpoint #99

Open

Description

@Levi-Ojukwu

Overview

The frontend polls the backend with React Query: market lists, market detail, trace previews, portfolio and resolution status are refetched on focus and on intervals. No endpoint tells clients or intermediaries how long a response is valid. No route sets Cache-Control, so browsers and CDNs apply heuristics or nothing, and every poll re-runs the full handler (Prisma queries, sometimes live RPC simulations), serializes the result, compresses it with compression(), and sends the whole body again even when nothing changed.

Express does attach a weak ETag to res.json() bodies by default and will answer a matching If-None-Match with 304, but only after the handler has done all its work and serialized the full response, so it saves bandwidth and none of the server cost. It is also unsafe in one specific place: GET /traces/:id uses optionalAuth and returns either a free preview or the full trace depending on the caller's daily pass. Without Cache-Control: private and Vary: Authorization, any shared cache that starts honouring these responses could serve a subscriber's full trace to an anonymous user.

This issue defines an explicit cache policy per route (public and long-lived for immutable data such as resolved markets and published trace previews, short max-age with stale-while-revalidate for lists, private, no-store for authenticated payment and allowance data), computes strong ETags cheaply from row versions (updatedAt, counts, indexed ledger) before doing the expensive work where possible, and answers If-None-Match with 304. It must work with the response envelope, the BigInt replacer and compression.

Background & current behavior

  • src/app.ts: helmet(), hpp(), CORS allowlist with credentials: true, globalLimiter, compression(), express.json, morgan; app.set('json replacer', ...) turns bigint into decimal strings. Express's etag setting is left at its default (weak ETags over the serialized body). Helmet does not set Cache-Control for API responses.
  • src/utils/response.util.ts: sendSuccess(res, data, statusCode = 200, meta?) builds { ok: true, data, error: null, meta } and calls res.status(code).json(body); sendError similarly. Every controller uses these helpers.
  • Routes (src/routes/*.routes.ts):
    • Markets: GET /markets (list, filters status, category, currency, onChain, pagination), GET /markets/on-chain/:onChainMarketId, GET /markets/on-chain/:onChainMarketId/state (live get_market simulation), GET /markets/:id, admin POST /markets/generate, authenticated GET /markets/generation-status/:jobId.
    • Traces: GET /traces (preview list), GET /traces/access/allowance and PUT (auth), GET /traces/payments (auth), GET /traces/:id (optionalAuth; getTracWithAccessControl(traceId, userId) returns preview with lockedFields/unlockPriceRaw/dailyPassPriceRaw or full trace with accessLevel), POST /traces/verify, POST /traces/:id/unlock.
    • Portfolio: GET /portfolio (DB summary + live treasury.available_capital), GET /portfolio/positions, GET /portfolio/stats.
    • Oracle: GET /oracle/markets/:marketId/resolution (live resolver + market-core reads).
  • Effectively immutable: RESOLVED/CANCELLED markets and trace previews once onChainTraceId is set (apart from verified).
  • Market.updatedAt and ReasoningTrace.updatedAt are @updatedAt columns; IndexerCursor.lastLedger gives the latest indexed ledger.

Goals

  • src/middlewares/cache.middleware.ts with declarative per-route policies.
  • Strong ETags derived from a stable validator (row versions, counts, ledger, policy version, auth class) or, when no cheap validator exists, from a canonical serialization of the response data.
  • 304 Not Modified on If-None-Match match, computed before the expensive part of the handler when a cheap validator exists.
  • Correct Vary (Authorization, Origin, Accept-Encoding) and private/no-store for anything personalised.
  • Never cache errors or non-2xx responses as fresh.
  • Tests verifying headers and 304 behaviour for every GET route.

Non-goals

  • Server-side response caching or chain-read caching (separate Redis chain-cache issue).
  • CDN configuration; the headers must be CDN-safe but no CDN is set up.
  • Cursor pagination changes (separate issue).
  • Last-Modified/If-Modified-Since (ETags only, to avoid second-granularity bugs).

Proposed implementation

  1. Policy model in new src/middlewares/cache.middleware.ts:
    export type CachePolicy =
      | { kind: 'immutable'; maxAge: number }                       // public, max-age=N, immutable
      | { kind: 'shared'; maxAge: number; swr: number }             // public, max-age=N, stale-while-revalidate=M
      | { kind: 'personalised'; maxAge: number }                    // private, max-age=N, Vary: Authorization
      | { kind: 'no-store' };                                       // private, no-store
    export function cachePolicy(policy: CachePolicy | ((req: Request, res: Response) => CachePolicy)): RequestHandler;
    export function validator(compute: (req: Request) => Promise<string | null>): RequestHandler;
    cachePolicy stores the policy on res.locals.cachePolicy and installs a hook that, on 2xx responses only, sets Cache-Control, Vary and (if not already set) the ETag; on non-2xx it sets Cache-Control: no-store. validator computes a cheap version string; if it returns non-null it sets ETag: "<sha256(policyVersion|authClass|version)>" and, when req.headers['if-none-match'] matches (parse the list, handle * and W/ prefixes, compare strong), ends with 304 immediately, skipping the controller.
  2. Disable the default weak ETag for the API (app.set('etag', false) in src/app.ts) so there is exactly one ETag source, and implement the fallback: when no validator ran, sendSuccess() computes a strong ETag from canonicalJson(body) (reuse canonicalJson/sha256Hex from src/utils/hash.util.ts, which already sorts keys and stringifies bigint) and checks If-None-Match itself. Update sendSuccess in src/utils/response.util.ts to do this only if res.locals.cachePolicy exists and is not no-store.
  3. Auth class in the key: authClass(req) = anon | user:<userId> | pass (for traces: whether checkAccess grants full access). Including it in the ETag prevents a 304 for a preview being accepted by a client that now has a pass. For personalised routes the Vary: Authorization header plus private keeps shared caches out.
  4. Per-route policies and validators:
    • GET /markets → shared (max-age=5, stale-while-revalidate=30). Validator: SELECT max("updatedAt"), count(*) FROM markets WHERE <same filters> via a helper that reuses the zod-parsed filters from listMarketsSchema, plus page/limit. Export the where builder from src/controllers/market.controller.ts so validator and handler share it.
    • GET /markets/:id, GET /markets/on-chain/:onChainMarketId → policy function: immutable (max-age=86400) if the market is RESOLVED/CANCELLED, else shared (max-age=5, swr=30). Validator: market.updatedAt + latest trace updatedAt + _count values (the detail embeds 5 trace previews and counts).
    • GET /markets/on-chain/:onChainMarketId/state → shared (max-age=5, swr=10); ETag from canonical body (it is chain data; if the chain-cache issue lands, use its ledger).
    • GET /traces → shared (max-age=10, swr=60); validator from max(updatedAt), count(*) with the same filters.
    • GET /traces/:id → personalised always (private, max-age=0, must-revalidate, Vary: Authorization), ETag including authClass and trace.updatedAt. Anonymous preview of a published trace (onChainTraceId set) may be shared with max-age=300 only when no Authorization header is present; when a token is present the response is always private.
    • GET /portfolio, /portfolio/positions, /portfolio/stats → shared (max-age=15, swr=60); validator for positions/stats from max(updatedAt)/counts; /portfolio uses the canonical-body fallback because it includes a live chain read.
    • GET /oracle/markets/:marketId/resolution → immutable when resolverState === 'Finalized' and market resolved, else shared (max-age=5); ETag from canonical body.
    • GET /traces/access/allowance, GET /traces/payments, GET /markets/generation-status/:jobId → no-store.
    • All non-GET routes → no-store (installed once on the router for non-GET methods in src/routes/index.ts).
  5. /health → no-store.
  6. Compression: strong ETags describe the identity representation; when compression() encodes the body the representation changes. Either append the encoding to the ETag ("<hash>-gzip") by checking res.getHeader('Content-Encoding') in an on-headers hook, or mark ETags weak when encoded. Choose the suffix approach and add Vary: Accept-Encoding (compression adds it; verify it survives).
  7. CORS: add ETag to exposedHeaders in the CORS options in src/app.ts so browser code can read it; verify preflights allow If-None-Match.
  8. Docs: add a "Caching" section to docs/api.md with the policy table, ETag semantics (opaque, changes with access level), and React Query guidance.

Edge cases & pitfalls

  • A user buys a daily pass: their next GET /traces/:id must not get 304 for the preview; the authClass in the ETag ensures a mismatch.
  • optionalAuth with an invalid token falls back to anonymous; such responses must still be private because the request carried Authorization.
  • Errors (404 MARKET_NOT_FOUND, 502 CHAIN_ERROR) must never get long max-age; a transient RPC failure cached for a day would be a severe bug.
  • If-None-Match: * on GET must not return 304 for a non-existent resource.
  • Pagination meta changes when rows are inserted on earlier pages; include count(*) in list validators.
  • updatedAt precision is milliseconds; two updates in the same millisecond are possible. Include counts or ids where it matters, and document the residual risk.

Testing plan

New tests/http-cache.test.ts using withServer from tests/http.test.ts with monkeypatched prisma methods (as tests/indexer.test.ts does) and stubbed chain reads:

  • every GET route sets the documented Cache-Control and Vary headers (table-driven over the policy table)
  • matching If-None-Match returns 304 with no body and skips the controller for validator routes (assert the handler stub was not called)
  • changing updatedAt changes the ETag
  • resolved market detail is immutable; active market detail is short-lived
  • trace detail ETag differs between anonymous, user without pass and user with pass
  • requests with Authorization never get a public Cache-Control
  • errors get no-store
  • compressed and uncompressed responses have different ETags
  • ETag is exposed to CORS clients
  • authenticated payment and allowance routes are no-store

Commands: npm test, npm run build.

Acceptance criteria

  • src/middlewares/cache.middleware.ts implements policies, validators and If-None-Match handling with strong ETag comparison.
  • Express's default weak ETag is disabled and sendSuccess() provides the canonical-body fallback ETag.
  • Every GET route has an explicit policy matching the documented table; non-GET routes and errors are no-store.
  • Validator-backed routes return 304 without running the controller (tested).
  • GET /traces/:id responses are private with Vary: Authorization whenever a token is present, and ETags include the access class.
  • Compressed responses carry encoding-specific ETags and Vary: Accept-Encoding.
  • ETag is exposed via CORS.
  • docs/api.md documents caching behaviour and client guidance.
  • npm run build, npm test and CI pass.

Resources


Important

Raise your pull request against the develop branch, not main.

All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.


Generated by Claude Code

Activity

  1. rinolame commented on Oct 17, 2026

    @rinolame

    @rinolame has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    Hi Maintainer! I'd like to work on this issue. I'm confident I can complete this task and submit a PR within 24 hours.

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @rinolame to this issue.

  2. rubbathabrahband commented on Oct 17, 2026

    @rubbathabrahband

    @rubbathabrahband has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    maintainerrr assign this to me

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @rubbathabrahband to this issue.

  3. drips-wave commented on Oct 17, 2026

    @drips-wave

    Congratulations, @rubbathabrahband! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.

    🧑‍💻 @rubbathabrahband: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.

    Warning

    When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.

    🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.

    🌊 Happy Wave 🌊

  4. rubbathabrahband commented on Oct 17, 2026

    @rubbathabrahband

    Resolved in PR #174: #174

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

Metadata

Metadata

Labels

Stellar WaveIssues in the Stellar wave programapicomplexity: highDrips Wave High (200 points)enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions