Skip to content

Commit e25e190

Browse files
committed
feat(cache): serve stale articles on upstream failure
1 parent a364243 commit e25e190

10 files changed

Lines changed: 98 additions & 21 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ PORT=3000
88
# LOG_LEVEL=info
99
# GNEWS_BASE_URL=https://gnews.io/api/v4
1010
# HTTP_TIMEOUT_MS=15000
11+
# STALE_CACHE_TTL_SEC=3600
1112
# SHUTDOWN_TIMEOUT_MS=10000
1213
# RATE_LIMIT_MAX=120
1314
# RATE_LIMIT_WINDOW_MS=60000

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- Provider circuit breaker for repeated GNews failures, including Prometheus circuit event metrics and `503` short-circuit responses.
2626
- OpenAPI-generated TypeScript client types, a small v1 `NewsApiClient` wrapper, and CI drift checks for generated client output.
2727
- Root capability document that advertises v1 routes, legacy routes, docs, and observability endpoints.
28+
- Stale-on-error article cache fallback with `meta.cache=stale`, stale cache TTL configuration, and Prometheus stale cache metrics.
2829

2930
### Changed
3031

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ A search runs through a small, explicit pipeline — each stage is a separate, t
99
1. **Validate** — query params are checked before any network call: `query` is required, `count` is clamped (≤100), `lang`/`country` must be ISO two-letter codes, `from`/`to` must parse as ISO 8601, and `sortBy` ∈ {`publishedAt`, `relevance`}. Bad input fails fast with `400` instead of wasting an upstream call or quota.
1010
2. **Cache** — parameters are normalized into a deterministic key and read through a pluggable store (in-memory by default, Redis when `REDIS_URL` is set). Cache failures are logged/metriced but do not fail article requests; identical in-flight misses in the same process share one upstream call.
1111
3. **Upstream** — on a miss, GNews is called with a hard timeout; transport/provider failures surface as `502`, and repeated failures open a short circuit that returns `503` without amplifying the outage.
12-
4. **Observe** — each step emits structured Pino logs (carrying `x-request-id`), Prometheus counters (cache hit/miss, upstream outcome, latency histogram), and optional OpenTelemetry spans.
12+
4. **Observe** — each step emits structured Pino logs (carrying `x-request-id`), Prometheus counters (cache hit/miss/stale fallback, upstream outcome, latency histogram), and optional OpenTelemetry spans.
1313
5. **Respond** — legacy endpoints return raw article arrays, while `/api/v1/*` returns `{ data, meta }` envelopes with request/cache metadata and structured error bodies.
1414

1515
Full diagram and component notes live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
1616

1717
## Production-oriented features
1818

1919
- **Security:** [Helmet](https://helmetjs.github.io/) headers, configurable rate limiting, optional `TRUST_PROXY` for correct client IPs behind a load balancer, optional **`CLIENT_API_KEYS`** + `X-API-Key` on `/api/*`.
20-
- **Reliability:** Upstream HTTP timeouts, response validation, `502` for provider/transport failures, provider circuit breaker with `503` short-circuiting, graceful shutdown on `SIGTERM` / `SIGINT`.
21-
- **Observability:** JSON logs via [Pino](https://getpino.io/), `x-request-id`, **`GET /metrics`** ([Prometheus](https://prometheus.io/) text format), cache hit/miss/error/coalescing + upstream latency/circuit metrics, and optional **OpenTelemetry** traces to OTLP (`OTEL_EXPORTER_OTLP_*`).
20+
- **Reliability:** Upstream HTTP timeouts, response validation, stale-on-error cache fallback, `502` for provider/transport failures, provider circuit breaker with `503` short-circuiting, graceful shutdown on `SIGTERM` / `SIGINT`.
21+
- **Observability:** JSON logs via [Pino](https://getpino.io/), `x-request-id`, **`GET /metrics`** ([Prometheus](https://prometheus.io/) text format), cache hit/miss/stale/error/coalescing + upstream latency/circuit metrics, and optional **OpenTelemetry** traces to OTLP (`OTEL_EXPORTER_OTLP_*`).
2222
- **Kubernetes-style probes:** `GET /health` (liveness), `GET /ready` (readiness when the API key is configured).
2323
- **Supply chain:** `npm audit` in CI; **SPDX SBOM** artifacts; Docker builds with **SBOM + provenance**; **dependency review** on PRs; optional **SLSA-style lockfile attestation** on `main`; lockfile-only installs.
2424
- **Contract:** OpenAPI at **`GET /openapi.yaml`** (also on disk as [docs/openapi.yaml](docs/openapi.yaml)).
@@ -95,7 +95,7 @@ Base path: `/api`. Machine-readable schema: **`GET /openapi.yaml`** · source fi
9595
| `GET` | `/health` | Liveness: `{ "status": "ok", "uptime": number }`. |
9696
| `GET` | `/ready` | Readiness; `503` if `GNEWS_API_KEY` missing (non-test). |
9797
| `GET` | `/openapi.yaml` | OpenAPI 3 document (`application/yaml`). |
98-
| `GET` | `/metrics` | Prometheus metrics (skips rate limit), including HTTP totals, cache hit/miss/error/coalescing counts, and upstream latency. |
98+
| `GET` | `/metrics` | Prometheus metrics (skips rate limit), including HTTP totals, cache hit/miss/stale/error/coalescing counts, and upstream latency. |
9999
| `GET` | `/api/v1/articles` | Versioned search. Returns `{ data, meta }`, including normalized filters, cache status, and `requestId`. |
100100
| `GET` | `/api/v1/articles/search` | Alias for versioned search. |
101101
| `GET` | `/api/v1/articles/title/:title` | Exact title match with `{ data, meta }`, else structured `404`. |

docs/ARCHITECTURE.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ flowchart LR
2424
### A search, end to end
2525

2626
The cache is an optimization, not a dependency: hits skip the provider, misses are
27-
coalesced per process, and cache failures fall through to the upstream rather than failing
28-
the request.
27+
coalesced per process, cache failures fall through to the upstream rather than failing
28+
the request, and stale copies can serve reads when the provider fails.
2929

3030
```mermaid
3131
sequenceDiagram
@@ -44,8 +44,12 @@ sequenceDiagram
4444
else miss (read fail falls through here too)
4545
Svc->>Svc: coalesce identical in-flight misses
4646
Svc->>GNews: provider adapter: GET /api/v4/search (timeout)
47-
GNews-->>Svc: normalized articles (502 on provider/transport error)
47+
GNews-->>Svc: normalized articles
4848
Svc->>Cache: set(key, articles, TTL 600s)
49+
Svc->>Cache: set(stale key, articles, longer TTL)
50+
else upstream failure and stale copy exists
51+
Svc->>Cache: get(stale key)
52+
Cache-->>Svc: stale articles
4953
end
5054
Svc-->>API: articles
5155
API-->>Client: 200 + results (legacy) or { data, meta } (v1)
@@ -86,10 +90,12 @@ Article arrays are stored per normalized search key with a **600-second** TTL (`
8690

8791
The service treats the cache as a quota and latency optimization, not as a hard dependency. Read failures fall through to the upstream provider, write failures return the upstream response without caching it, and both paths emit warning logs plus cache error metrics. Within a single process, concurrent misses for the same normalized key are coalesced so only the first request calls the provider.
8892

93+
Successful upstream searches write both a fresh cache key and a longer-lived stale key. The fresh key protects latency and quota under normal conditions; the stale key is only read after an upstream failure. Versioned responses expose this through `meta.cache=stale`.
94+
8995
## Provider Circuit Breaker
9096

9197
The GNews provider adapter tracks consecutive provider failures. After `UPSTREAM_CIRCUIT_FAILURE_THRESHOLD` failures, it opens a cooldown window (`UPSTREAM_CIRCUIT_COOLDOWN_MS`) and returns `503` without making another upstream call. After the cooldown, one request is allowed through; success closes the circuit, while another failure opens it again.
9298

9399
## Metrics
94100

95-
`src/metrics/register.ts` exports a single Prometheus registry used by `/metrics`. HTTP middleware records response counts, while `newsService` and the provider adapter record cache hits/misses/errors/coalesced misses, upstream request outcomes, upstream latency buckets, and circuit breaker events.
101+
`src/metrics/register.ts` exports a single Prometheus registry used by `/metrics`. HTTP middleware records response counts, while `newsService` and the provider adapter record cache hits/misses/errors/coalesced/stale misses, upstream request outcomes, upstream latency buckets, and circuit breaker events.

docs/OPERATIONS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
| `LOG_LEVEL` | `info` (non-test) | Pino level (`trace``fatal`, or `silent`). |
1111
| `GNEWS_BASE_URL` | `https://gnews.io/api/v4` | Upstream provider base URL. Override only for local integration tests, benchmarks, or compatible provider mocks. |
1212
| `HTTP_TIMEOUT_MS` | `15000` | Outbound GNews request timeout (max `60000`). |
13+
| `STALE_CACHE_TTL_SEC` | `3600` | Longer-lived stale article cache TTL used only as an upstream-failure fallback (min effective value `>600`, max `86400`). |
1314
| `UPSTREAM_CIRCUIT_FAILURE_THRESHOLD` | `3` | Consecutive provider failures before the circuit opens. |
1415
| `UPSTREAM_CIRCUIT_COOLDOWN_MS` | `30000` | How long to short-circuit provider calls after the circuit opens (max `300000`). |
1516
| `SHUTDOWN_TIMEOUT_MS` | `10000` | Force-exit if `server.close` does not finish. |
@@ -40,6 +41,7 @@
4041
- Cache keys include normalized search parameters: query, count, `lang`, `country`, `from`, `to`, and `sortBy`.
4142
- Cache reads/writes are non-fatal for article searches. If the cache backend is unavailable, the service logs a warning, increments cache error metrics, falls through to GNews on read failure, and still returns the upstream response on write failure.
4243
- Identical in-flight misses are coalesced per process, so concurrent requests for the same normalized search wait on one upstream provider request.
44+
- Successful searches are also written to a longer-lived stale cache key. If a later fresh miss hits an upstream failure and stale data is available, `/api/v1/*` returns `meta.cache=stale` with a `200` response instead of surfacing the provider outage.
4345

4446
On shutdown the server closes the Redis connection when that backend was used.
4547

@@ -50,13 +52,13 @@ On shutdown the server closes the Redis connection when that backend was used.
5052
| Metric | Labels | Purpose |
5153
|--------|--------|---------|
5254
| `http_requests_total` | `method`, `status_code` | HTTP response count. |
53-
| `news_cache_events_total` | `result=hit|miss|error|coalesced` | Cache lookup and in-flight coalescing behavior for article searches. |
54-
| `news_cache_errors_total` | `operation=get|set` | Cache backend errors that were tolerated by falling through to upstream or returning an uncached upstream response. |
55+
| `news_cache_events_total` | `result=hit|miss|error|coalesced|stale` | Cache lookup, stale fallback, and in-flight coalescing behavior for article searches. |
56+
| `news_cache_errors_total` | `operation=get|set|get_stale|set_stale` | Cache backend errors that were tolerated by falling through to upstream, returning an uncached upstream response, or skipping stale fallback. |
5557
| `news_upstream_requests_total` | `outcome=success|error|invalid_payload` | GNews provider request outcomes. |
5658
| `news_upstream_request_duration_seconds` | `outcome=success|error|invalid_payload` | GNews provider request latency histogram. |
5759
| `news_upstream_circuit_events_total` | `event=opened|short_circuit|half_open|closed` | Provider circuit breaker state transitions and short-circuited requests. |
5860

59-
Use cache hit rate and coalesced miss counts to understand quota protection, cache error metrics to detect Redis/backend trouble, upstream latency/error metrics to separate provider trouble from local API trouble, and circuit events to see when repeated provider failures are being shed locally.
61+
Use cache hit rate and coalesced miss counts to understand quota protection, stale counts to see when provider trouble is being hidden by cached data, cache error metrics to detect Redis/backend trouble, upstream latency/error metrics to separate provider trouble from local API trouble, and circuit events to see when repeated provider failures are being shed locally.
6062

6163
## Docker
6264

docs/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ components:
705705
additionalProperties: true
706706
cache:
707707
type: string
708-
enum: [hit, miss, coalesced]
708+
enum: [hit, miss, coalesced, stale]
709709
requestId:
710710
type: string
711711

src/cache/store.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const TTL_SEC = 600;
66

77
export type CacheStore = {
88
get(key: string): Promise<unknown | undefined>;
9-
set(key: string, value: unknown): Promise<void>;
9+
set(key: string, value: unknown, ttlSec?: number): Promise<void>;
1010
};
1111

1212
let singleton: CacheStore | null = null;
@@ -18,8 +18,8 @@ function createMemoryStore(): CacheStore {
1818
async get(key: string) {
1919
return c.get(key);
2020
},
21-
async set(key: string, value: unknown) {
22-
c.set(key, value);
21+
async set(key: string, value: unknown, ttlSec = TTL_SEC) {
22+
c.set(key, value, ttlSec);
2323
},
2424
};
2525
}
@@ -46,8 +46,8 @@ function createRedisStore(url: string): CacheStore {
4646
return undefined;
4747
}
4848
},
49-
async set(key: string, value: unknown) {
50-
await client.setex(key, TTL_SEC, JSON.stringify(value));
49+
async set(key: string, value: unknown, ttlSec = TTL_SEC) {
50+
await client.setex(key, ttlSec, JSON.stringify(value));
5151
},
5252
};
5353
}

src/client/openapi-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ export interface components {
285285
[key: string]: unknown;
286286
};
287287
/** @enum {string} */
288-
cache: "hit" | "miss" | "coalesced";
288+
cache: "hit" | "miss" | "coalesced" | "stale";
289289
requestId: string;
290290
};
291291
ArticleSearchEnvelope: {

src/services/newsService.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { logger } from "../logger";
66
import { newsProvider } from "../providers/gnewsProvider";
77

88
const inFlightSearches = new Map<string, Promise<ArticleSearchResult>>();
9+
const STALE_CACHE_TTL_SEC = resolveStaleCacheTtlSec();
910

10-
export type ArticleSearchCacheStatus = "hit" | "miss" | "coalesced";
11+
export type ArticleSearchCacheStatus = "hit" | "miss" | "coalesced" | "stale";
1112

1213
export interface ArticleSearchResult {
1314
articles: Article[];
@@ -26,6 +27,15 @@ function searchCacheKey(options: ArticleSearchOptions): string {
2627
});
2728
}
2829

30+
function staleCacheKey(cacheKey: string): string {
31+
return `${cacheKey}:stale`;
32+
}
33+
34+
function resolveStaleCacheTtlSec(): number {
35+
const raw = Number(process.env.STALE_CACHE_TTL_SEC ?? 3_600);
36+
return Number.isFinite(raw) && raw > 600 ? Math.min(Math.floor(raw), 86_400) : 3_600;
37+
}
38+
2939
async function readCachedArticles(
3040
store: CacheStore,
3141
cacheKey: string
@@ -47,6 +57,24 @@ async function readCachedArticles(
4757
return undefined;
4858
}
4959

60+
async function readStaleCachedArticles(
61+
store: CacheStore,
62+
cacheKey: string
63+
): Promise<ArticleSearchResult | undefined> {
64+
try {
65+
const cached = await store.get(staleCacheKey(cacheKey));
66+
if (cached !== undefined) {
67+
cacheEventsTotal.inc({ result: "stale" });
68+
return { articles: cached as Article[], cache: "stale" };
69+
}
70+
} catch (err) {
71+
cacheErrorsTotal.inc({ operation: "get_stale" });
72+
logger.warn({ err }, "stale cache get failed; returning upstream error");
73+
}
74+
75+
return undefined;
76+
}
77+
5078
async function writeCachedArticles(
5179
store: CacheStore,
5280
cacheKey: string,
@@ -58,14 +86,32 @@ async function writeCachedArticles(
5886
cacheErrorsTotal.inc({ operation: "set" });
5987
logger.warn({ err }, "cache set failed; returning upstream response without caching");
6088
}
89+
90+
try {
91+
await store.set(staleCacheKey(cacheKey), articles, STALE_CACHE_TTL_SEC);
92+
} catch (err) {
93+
cacheErrorsTotal.inc({ operation: "set_stale" });
94+
logger.warn({ err }, "stale cache set failed; returning upstream response without stale copy");
95+
}
6196
}
6297

6398
async function fetchArticlesFromUpstream(
6499
options: ArticleSearchOptions,
65100
store: CacheStore,
66101
cacheKey: string
67102
): Promise<ArticleSearchResult> {
68-
const articles = await newsProvider.search(options);
103+
let articles: Article[];
104+
try {
105+
articles = await newsProvider.search(options);
106+
} catch (err) {
107+
const stale = await readStaleCachedArticles(store, cacheKey);
108+
if (stale) {
109+
logger.warn({ err }, "upstream failed; returning stale cached articles");
110+
return stale;
111+
}
112+
throw err;
113+
}
114+
69115
await writeCachedArticles(store, cacheKey, articles);
70116
return { articles, cache: "miss" };
71117
}
@@ -84,7 +130,7 @@ export const searchArticles = async (
84130
if (existing) {
85131
cacheEventsTotal.inc({ result: "coalesced" });
86132
const result = await existing;
87-
return { ...result, cache: "coalesced" };
133+
return { ...result, cache: result.cache === "miss" ? "coalesced" : result.cache };
88134
}
89135

90136
const search = fetchArticlesFromUpstream(options, store, cacheKey);

test/app.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,27 @@ describe("app", () => {
252252
expect(mockGet).toHaveBeenCalledTimes(1);
253253
});
254254

255+
it("returns stale cached articles when upstream fails after a fresh miss", async () => {
256+
const staleArticles = sampleArticles.slice(0, 1);
257+
setCacheStoreForTests({
258+
async get(key) {
259+
return key.endsWith(":stale") ? staleArticles : undefined;
260+
},
261+
async set() {
262+
return undefined;
263+
},
264+
});
265+
mockGet.mockRejectedValueOnce(new axios.AxiosError("timeout"));
266+
267+
const q = `stale-${Math.random().toString(36).slice(2)}`;
268+
const res = await request(app).get(`/api/v1/articles?query=${encodeURIComponent(q)}&count=2`);
269+
270+
expect(res.status).toBe(200);
271+
expect(res.body.data).toEqual(staleArticles);
272+
expect(res.body.meta.cache).toBe("stale");
273+
expect(mockGet).toHaveBeenCalledTimes(1);
274+
});
275+
255276
it("coalesces identical in-flight cache misses", async () => {
256277
mockGet.mockImplementationOnce(
257278
() =>

0 commit comments

Comments
 (0)