• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hirosystems / stacks-blockchain-api / 4939384389

pending completion
4939384389

Pull #1655

github

GitHub
Merge 14bb9f2c6 into adbe2b16a
Pull Request #1655: Develop

2142 of 3228 branches covered (66.36%)

134 of 234 new or added lines in 42 files covered. (57.26%)

7491 of 9606 relevant lines covered (77.98%)

1796.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.12
/src/api/controllers/cache-controller.ts
1
import { RequestHandler, Request, Response } from 'express';
2
import * as prom from 'prom-client';
53✔
3
import { normalizeHashString, sha256 } from '../../helpers';
53✔
4
import { asyncHandler } from '../async-handler';
53✔
5
import { PgStore } from '../../datastore/pg-store';
6
import { logger } from '../../logger';
53✔
7

8
const CACHE_OK = Symbol('cache_ok');
53✔
9

10
/**
11
 * A `Cache-Control` header used for re-validation based caching.
12
 * * `public` == allow proxies/CDNs to cache as opposed to only local browsers.
13
 * * `no-cache` == clients can cache a resource but should revalidate each time before using it.
14
 * * `must-revalidate` == somewhat redundant directive to assert that cache must be revalidated, required by some CDNs
15
 */
16
const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate';
53✔
17

18
/**
19
 * Describes a key-value to be saved into a request's locals, representing the current
20
 * state of the chain depending on the type of information being requested by the endpoint.
21
 * This entry will have an `ETag` string as the value.
22
 */
23
export enum ETagType {
53✔
24
  /** ETag based on the latest `index_block_hash` or `microblock_hash`. */
25
  chainTip = 'chain_tip',
53✔
26
  /** ETag based on a digest of all pending mempool `tx_id`s. */
27
  mempool = 'mempool',
53✔
28
  /** ETag based on the status of a single transaction across the mempool or canonical chain. */
29
  transaction = 'transaction',
53✔
30
}
31

32
/** Value that means the ETag did get calculated but it is empty. */
33
const ETAG_EMPTY = Symbol(-1);
53✔
34
type ETag = string | typeof ETAG_EMPTY;
35

36
interface ETagCacheMetrics {
37
  chainTipCacheHits: prom.Counter<string>;
38
  chainTipCacheMisses: prom.Counter<string>;
39
  chainTipCacheNoHeader: prom.Counter<string>;
40
  mempoolCacheHits: prom.Counter<string>;
41
  mempoolCacheMisses: prom.Counter<string>;
42
  mempoolCacheNoHeader: prom.Counter<string>;
43
}
44

45
let _eTagMetrics: ETagCacheMetrics | undefined;
46
function getETagMetrics(): ETagCacheMetrics {
47
  if (_eTagMetrics !== undefined) {
716✔
48
    return _eTagMetrics;
689✔
49
  }
50
  const metrics: ETagCacheMetrics = {
27✔
51
    chainTipCacheHits: new prom.Counter({
52
      name: 'chain_tip_cache_hits',
53
      help: 'Total count of requests with an up-to-date chain tip cache header',
54
    }),
55
    chainTipCacheMisses: new prom.Counter({
56
      name: 'chain_tip_cache_misses',
57
      help: 'Total count of requests with a stale chain tip cache header',
58
    }),
59
    chainTipCacheNoHeader: new prom.Counter({
60
      name: 'chain_tip_cache_no_header',
61
      help: 'Total count of requests that did not provide a chain tip header',
62
    }),
63
    mempoolCacheHits: new prom.Counter({
64
      name: 'mempool_cache_hits',
65
      help: 'Total count of requests with an up-to-date mempool cache header',
66
    }),
67
    mempoolCacheMisses: new prom.Counter({
68
      name: 'mempool_cache_misses',
69
      help: 'Total count of requests with a stale mempool cache header',
70
    }),
71
    mempoolCacheNoHeader: new prom.Counter({
72
      name: 'mempool_cache_no_header',
73
      help: 'Total count of requests that did not provide a mempool header',
74
    }),
75
  };
76
  _eTagMetrics = metrics;
27✔
77
  return _eTagMetrics;
27✔
78
}
79

80
export function setResponseNonCacheable(res: Response) {
53✔
81
  res.removeHeader('Cache-Control');
18✔
82
  res.removeHeader('ETag');
18✔
83
}
84

85
/**
86
 * Sets the response `Cache-Control` and `ETag` headers using the etag previously added
87
 * to the response locals.
88
 */
89
export function setETagCacheHeaders(res: Response, etagType: ETagType = ETagType.chainTip) {
53✔
90
  const etag: ETag | undefined = res.locals[etagType];
656✔
91
  if (!etag) {
656✔
92
    logger.error(
21✔
93
      `Cannot set cache control headers, no etag was set on \`Response.locals[${etagType}]\`.`
94
    );
95
    return;
21✔
96
  }
97
  if (etag === ETAG_EMPTY) {
635!
98
    return;
×
99
  }
100
  res.set({
635✔
101
    'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE,
102
    // Use the current chain tip or mempool state as the etag so that cache is invalidated on new blocks or
103
    // new mempool events.
104
    // This value will be provided in the `If-None-Match` request header in subsequent requests.
105
    // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
106
    // > Entity tag that uniquely represents the requested resource.
107
    // > It is a string of ASCII characters placed between double quotes..
108
    ETag: `"${etag}"`,
109
  });
110
}
111

112
/**
113
 * Parses the etag values from a raw `If-None-Match` request header value.
114
 * The wrapping double quotes (if any) and validation prefix (if any) are stripped.
115
 * The parsing is permissive to account for commonly non-spec-compliant clients, proxies, CDNs, etc.
116
 * E.g. the value:
117
 * ```js
118
 * `"a", W/"b", c,d,   "e", "f"`
119
 * ```
120
 * Would be parsed and returned as:
121
 * ```js
122
 * ['a', 'b', 'c', 'd', 'e', 'f']
123
 * ```
124
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#syntax
125
 * ```
126
 * If-None-Match: "etag_value"
127
 * If-None-Match: "etag_value", "etag_value", ...
128
 * If-None-Match: *
129
 * ```
130
 * @param ifNoneMatchHeaderValue - raw header value
131
 * @returns an array of etag values
132
 */
133
export function parseIfNoneMatchHeader(
53✔
134
  ifNoneMatchHeaderValue: string | undefined
135
): string[] | undefined {
136
  if (!ifNoneMatchHeaderValue) {
677✔
137
    return undefined;
649✔
138
  }
139
  // Strip wrapping double quotes like `"hello"` and the ETag validation-prefix like `W/"hello"`.
140
  // The API returns compliant, strong-validation ETags (double quoted ASCII), but can't control what
141
  // clients, proxies, CDNs, etc may provide.
142
  const normalized = /^(?:"|W\/")?(.*?)"?$/gi.exec(ifNoneMatchHeaderValue.trim())?.[1];
28✔
143
  if (!normalized) {
28✔
144
    // This should never happen unless handling a buggy request with something like `If-None-Match: ""`,
145
    // or if there's a flaw in the above code. Log warning for now.
146
    logger.warn(`Normalized If-None-Match header is falsy: ${ifNoneMatchHeaderValue}`);
1✔
147
    return undefined;
1✔
148
  } else if (normalized.includes(',')) {
27✔
149
    // Multiple etag values provided, likely irrelevant extra values added by a proxy/CDN.
150
    // Split on comma, also stripping quotes, weak-validation prefixes, and extra whitespace.
151
    return normalized.split(/(?:W\/"|")?(?:\s*),(?:\s*)(?:W\/"|")?/gi);
5✔
152
  } else {
153
    // Single value provided (the typical case)
154
    return [normalized];
22✔
155
  }
156
}
157

158
/**
159
 * Parse the `ETag` from the given request's `If-None-Match` header which represents the chain tip or
160
 * mempool state associated with the client's cached response. Query the current state from the db, and
161
 * compare the two.
162
 * This function is also responsible for tracking the prometheus metrics associated with cache hits/misses.
163
 * @returns `CACHE_OK` if the client's cached response is up-to-date with the current state, otherwise,
164
 * returns a string which can be used later for setting the cache control `ETag` response header.
165
 */
166
async function checkETagCacheOK(
167
  db: PgStore,
168
  req: Request,
169
  etagType: ETagType
170
): Promise<ETag | undefined | typeof CACHE_OK> {
171
  const metrics = getETagMetrics();
716✔
172
  const etag = await calculateETag(db, etagType, req);
716✔
173
  if (!etag || etag === ETAG_EMPTY) {
716✔
174
    return;
50✔
175
  }
176
  // Parse ETag values from the request's `If-None-Match` header, if any.
177
  // Note: node.js normalizes `IncomingMessage.headers` to lowercase.
178
  const ifNoneMatch = parseIfNoneMatchHeader(req.headers['if-none-match']);
666✔
179
  if (ifNoneMatch === undefined || ifNoneMatch.length === 0) {
666✔
180
    // No if-none-match header specified.
181
    if (etagType === ETagType.chainTip) {
647✔
182
      metrics.chainTipCacheNoHeader.inc();
589✔
183
    } else {
184
      metrics.mempoolCacheNoHeader.inc();
58✔
185
    }
186
    return etag;
647✔
187
  }
188
  if (ifNoneMatch.includes(etag)) {
19✔
189
    // The client cache's ETag matches the current state, so no need to re-process the request
190
    // server-side as there will be no change in response. Record this as a "cache hit" and return CACHE_OK.
191
    if (etagType === ETagType.chainTip) {
9✔
192
      metrics.chainTipCacheHits.inc();
4✔
193
    } else {
194
      metrics.mempoolCacheHits.inc();
5✔
195
    }
196
    return CACHE_OK;
9✔
197
  } else {
198
    // The client cache's ETag is associated with an different block than current latest state, typically
199
    // an older block or a forked block, so the client's cached response is stale and should not be used.
200
    // Record this as a "cache miss" and return the current state.
201
    if (etagType === ETagType.chainTip) {
10✔
202
      metrics.chainTipCacheMisses.inc();
3✔
203
    } else {
204
      metrics.mempoolCacheMisses.inc();
7✔
205
    }
206
    return etag;
10✔
207
  }
208
}
209

210
/**
211
 * Check if the request has an up-to-date cached response by comparing the `If-None-Match` request header to the
212
 * current state. If the cache is valid then a `304 Not Modified` response is sent and the route handling for
213
 * this request is completed. If the cache is outdated, the current state is added to the `Request.locals` for
214
 * later use in setting response cache headers.
215
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#freshness
216
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
217
 * ```md
218
 * The If-None-Match HTTP request header makes the request conditional. For GET and HEAD methods, the server
219
 * will return the requested resource, with a 200 status, only if it doesn't have an ETag matching the given
220
 * ones. For other methods, the request will be processed only if the eventually existing resource's ETag
221
 * doesn't match any of the values listed.
222
 * ```
223
 */
224
export function getETagCacheHandler(
53✔
225
  db: PgStore,
226
  etagType: ETagType = ETagType.chainTip
2,240✔
227
): RequestHandler {
228
  const requestHandler = asyncHandler(async (req, res, next) => {
2,912✔
229
    const result = await checkETagCacheOK(db, req, etagType);
716✔
230
    if (result === CACHE_OK) {
716✔
231
      // Instruct the client to use the cached response via a `304 Not Modified` response header.
232
      // This completes the handling for this request, do not call `next()` in order to skip the
233
      // router handler used for non-cached responses.
234
      res.set('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).status(304).send();
9✔
235
    } else {
236
      // Request does not have a valid cache. Store the etag for later
237
      // use in setting response cache headers.
238
      const etag: ETag | undefined = result;
707✔
239
      res.locals[etagType] = etag;
707✔
240
      next();
707✔
241
    }
242
  });
243
  return requestHandler;
2,912✔
244
}
245

246
async function calculateETag(
247
  db: PgStore,
248
  etagType: ETagType,
249
  req: Request
250
): Promise<ETag | undefined> {
251
  switch (etagType) {
716✔
252
    case ETagType.chainTip:
253
      try {
631✔
254
        const chainTip = await db.getUnanchoredChainTip();
631✔
255
        if (!chainTip.found) {
630✔
256
          // This should never happen unless the API is serving requests before it has synced any
257
          // blocks.
258
          return;
34✔
259
        }
260
        return chainTip.result.microblockHash ?? chainTip.result.indexBlockHash;
596✔
261
      } catch (error) {
262
        logger.error(error, 'Unable to calculate chain_tip ETag');
1✔
263
        return;
1✔
264
      }
265

266
    case ETagType.mempool:
267
      try {
31✔
268
        const digest = await db.getMempoolTxDigest();
31✔
269
        if (!digest.found) {
31!
270
          // This should never happen unless the API is serving requests before it has synced any
271
          // blocks.
272
          return;
×
273
        }
274
        if (digest.result.digest === null) {
31✔
275
          // A `null` mempool digest means the `bit_xor` postgres function is unavailable.
276
          return ETAG_EMPTY;
1✔
277
        }
278
        return digest.result.digest;
30✔
279
      } catch (error) {
NEW
280
        logger.error(error, 'Unable to calculate mempool');
×
281
        return;
×
282
      }
283

284
    case ETagType.transaction:
285
      try {
54✔
286
        const { tx_id } = req.params;
54✔
287
        const normalizedTxId = normalizeHashString(tx_id);
54✔
288
        if (normalizedTxId === false) {
54✔
289
          return ETAG_EMPTY;
12✔
290
        }
291
        const status = await db.getTxStatus(normalizedTxId);
42✔
292
        if (!status.found) {
42✔
293
          return ETAG_EMPTY;
2✔
294
        }
295
        const elements: string[] = [
40✔
296
          normalizedTxId,
297
          status.result.index_block_hash ?? '',
54✔
298
          status.result.microblock_hash ?? '',
54✔
299
          status.result.status.toString(),
300
        ];
301
        return sha256(elements.join(':'));
40✔
302
      } catch (error) {
NEW
303
        logger.error(error, 'Unable to calculate transaction');
×
304
        return;
×
305
      }
306
  }
307
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc