Skip to content

Commit b4561fc

Browse files
committed
feat(provider): add upstream circuit breaker
1 parent 6622642 commit b4561fc

8 files changed

Lines changed: 129 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Docker healthcheck now honors the runtime `PORT` environment variable.
2323
- Versioned `/api/v1/*` endpoints with `{ data, meta }` response envelopes, cache metadata, and structured error bodies.
2424
- OpenAPI-backed response contract tests for the v1 success and structured error envelopes.
25+
- Provider circuit breaker for repeated GNews failures, including Prometheus circuit event metrics and `503` short-circuit responses.
2526

2627
### Changed
2728

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A search runs through a small, explicit pipeline — each stage is a separate, t
88

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.
11-
3. **Upstream** — on a miss, GNews is called with a hard timeout; transport/provider failures surface as `502` rather than a hung request.
11+
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.
1212
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.
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

@@ -17,8 +17,8 @@ Full diagram and component notes live in [docs/ARCHITECTURE.md](docs/ARCHITECTUR
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, 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 metrics, and optional **OpenTelemetry** traces to OTLP (`OTEL_EXPORTER_OTLP_*`).
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_*`).
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)).

docs/ARCHITECTURE.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ flowchart LR
1717
2. **Express** (`src/app.ts`) applies middleware in order: trust-proxy (optional), **Pino** request logging, **metrics** observer, **Helmet**, JSON body parser, **rate limiting** (skips `/health`, `/ready`, `/openapi.yaml`, `/metrics`), then mounts `/api` routes.
1818
3. **Controllers** validate query parameters and map domain results to HTTP status codes.
1919
4. **News service** builds cache keys from normalized search parameters (`query`, `count`, `lang`, `country`, `from`, `to`, `sortBy`), reads through `getCacheStore()` (in-memory or **Redis** when `REDIS_URL` is set), coalesces identical in-flight misses per process, and delegates upstream fetches to the GNews provider adapter. Cache backend errors are logged and metriced without failing the article request.
20-
5. **Response mapping** keeps legacy `/api/articles*` endpoints backward compatible with raw arrays, while `/api/v1/*` returns `{ data, meta }` envelopes with `requestId`, normalized filters, and cache status.
21-
6. **Title** and **source** endpoints reuse the search call, then narrow results in memory (exact title match; case-insensitive source name match).
20+
5. **Provider adapter** maps domain search options to GNews parameters, validates provider payloads, records upstream metrics, and opens a short circuit after repeated provider failures so outages are shed locally instead of amplified.
21+
6. **Response mapping** keeps legacy `/api/articles*` endpoints backward compatible with raw arrays, while `/api/v1/*` returns `{ data, meta }` envelopes with `requestId`, normalized filters, and cache status.
22+
7. **Title** and **source** endpoints reuse the search call, then narrow results in memory (exact title match; case-insensitive source name match).
2223

2324
### A search, end to end
2425

@@ -85,6 +86,10 @@ Article arrays are stored per normalized search key with a **600-second** TTL (`
8586

8687
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.
8788

89+
## Provider Circuit Breaker
90+
91+
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.
92+
8893
## Metrics
8994

90-
`src/metrics/register.ts` exports a single Prometheus registry used by `/metrics`. HTTP middleware records response counts, while `newsService` records cache hits/misses/errors/coalesced misses, upstream request outcomes, and upstream latency buckets.
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.

docs/OPERATIONS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
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+
| `UPSTREAM_CIRCUIT_FAILURE_THRESHOLD` | `3` | Consecutive provider failures before the circuit opens. |
14+
| `UPSTREAM_CIRCUIT_COOLDOWN_MS` | `30000` | How long to short-circuit provider calls after the circuit opens (max `300000`). |
1315
| `SHUTDOWN_TIMEOUT_MS` | `10000` | Force-exit if `server.close` does not finish. |
1416
| `RATE_LIMIT_MAX` | `120` | Max requests per IP per window. |
1517
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window. |
@@ -52,8 +54,9 @@ On shutdown the server closes the Redis connection when that backend was used.
5254
| `news_cache_errors_total` | `operation=get|set` | Cache backend errors that were tolerated by falling through to upstream or returning an uncached upstream response. |
5355
| `news_upstream_requests_total` | `outcome=success|error|invalid_payload` | GNews provider request outcomes. |
5456
| `news_upstream_request_duration_seconds` | `outcome=success|error|invalid_payload` | GNews provider request latency histogram. |
57+
| `news_upstream_circuit_events_total` | `event=opened|short_circuit|half_open|closed` | Provider circuit breaker state transitions and short-circuited requests. |
5558

56-
Use cache hit rate and coalesced miss counts to understand quota protection, cache error metrics to detect Redis/backend trouble, and upstream latency/error metrics to separate provider trouble from local API trouble.
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.
5760

5861
## Docker
5962

docs/openapi.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ paths:
103103
description: Rate limit exceeded
104104
"502":
105105
$ref: "#/components/responses/V1BadGateway"
106+
"503":
107+
$ref: "#/components/responses/V1ServiceUnavailable"
106108
"500":
107109
$ref: "#/components/responses/V1InternalError"
108110

@@ -137,6 +139,8 @@ paths:
137139
description: Rate limit exceeded
138140
"502":
139141
$ref: "#/components/responses/V1BadGateway"
142+
"503":
143+
$ref: "#/components/responses/V1ServiceUnavailable"
140144
"500":
141145
$ref: "#/components/responses/V1InternalError"
142146

@@ -173,6 +177,8 @@ paths:
173177
description: Rate limit exceeded
174178
"502":
175179
$ref: "#/components/responses/V1BadGateway"
180+
"503":
181+
$ref: "#/components/responses/V1ServiceUnavailable"
176182
"500":
177183
$ref: "#/components/responses/V1InternalError"
178184

@@ -211,6 +217,8 @@ paths:
211217
description: Rate limit exceeded
212218
"502":
213219
$ref: "#/components/responses/V1BadGateway"
220+
"503":
221+
$ref: "#/components/responses/V1ServiceUnavailable"
214222
"500":
215223
$ref: "#/components/responses/V1InternalError"
216224

@@ -293,6 +301,8 @@ paths:
293301
description: Rate limit exceeded
294302
"502":
295303
$ref: "#/components/responses/BadGateway"
304+
"503":
305+
description: Upstream provider circuit breaker is open
296306
"500":
297307
$ref: "#/components/responses/InternalError"
298308

@@ -334,6 +344,8 @@ paths:
334344
description: Rate limit exceeded
335345
"502":
336346
$ref: "#/components/responses/BadGateway"
347+
"503":
348+
description: Upstream provider circuit breaker is open
337349
"500":
338350
$ref: "#/components/responses/InternalError"
339351

@@ -416,6 +428,8 @@ paths:
416428
description: Rate limit exceeded
417429
"502":
418430
$ref: "#/components/responses/BadGateway"
431+
"503":
432+
description: Upstream provider circuit breaker is open
419433
"500":
420434
$ref: "#/components/responses/InternalError"
421435

@@ -540,6 +554,12 @@ components:
540554
application/json:
541555
schema:
542556
$ref: "#/components/schemas/ErrorEnvelope"
557+
V1ServiceUnavailable:
558+
description: Upstream provider circuit breaker is open
559+
content:
560+
application/json:
561+
schema:
562+
$ref: "#/components/schemas/ErrorEnvelope"
543563

544564
schemas:
545565
ErrorBody:

src/metrics/register.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,10 @@ export const upstreamRequestDurationSeconds = new Histogram({
3939
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15],
4040
registers: [register],
4141
});
42+
43+
export const upstreamCircuitEventsTotal = new Counter({
44+
name: "news_upstream_circuit_events_total",
45+
help: "Total upstream provider circuit breaker events",
46+
labelNames: ["event"],
47+
registers: [register],
48+
});

src/providers/gnewsProvider.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import axios from "axios";
22
import { UPSTREAM_BASE_URL, UPSTREAM_TIMEOUT_MS } from "../config/upstream";
33
import { HttpError } from "../errors/HttpError";
44
import {
5+
upstreamCircuitEventsTotal,
56
upstreamRequestDurationSeconds,
67
upstreamRequestsTotal,
78
} from "../metrics/register";
@@ -12,6 +13,66 @@ export interface NewsProvider {
1213
search(options: ArticleSearchOptions): Promise<Article[]>;
1314
}
1415

16+
interface CircuitState {
17+
failures: number;
18+
openedAt: number | undefined;
19+
}
20+
21+
const circuit: CircuitState = {
22+
failures: 0,
23+
openedAt: undefined,
24+
};
25+
26+
function failureThreshold(): number {
27+
const raw = Number(process.env.UPSTREAM_CIRCUIT_FAILURE_THRESHOLD ?? 3);
28+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 3;
29+
}
30+
31+
function cooldownMs(): number {
32+
const raw = Number(process.env.UPSTREAM_CIRCUIT_COOLDOWN_MS ?? 30_000);
33+
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 300_000) : 30_000;
34+
}
35+
36+
function assertCircuitAllowsRequest(now = Date.now()): void {
37+
if (circuit.openedAt === undefined) {
38+
return;
39+
}
40+
41+
if (now - circuit.openedAt < cooldownMs()) {
42+
upstreamCircuitEventsTotal.inc({ event: "short_circuit" });
43+
throw new HttpError(
44+
503,
45+
"Upstream news service temporarily unavailable",
46+
"upstream_circuit_open"
47+
);
48+
}
49+
50+
circuit.openedAt = undefined;
51+
upstreamCircuitEventsTotal.inc({ event: "half_open" });
52+
}
53+
54+
function recordProviderSuccess(): void {
55+
if (circuit.failures > 0 || circuit.openedAt !== undefined) {
56+
upstreamCircuitEventsTotal.inc({ event: "closed" });
57+
}
58+
circuit.failures = 0;
59+
circuit.openedAt = undefined;
60+
}
61+
62+
function recordProviderFailure(): void {
63+
circuit.failures += 1;
64+
if (circuit.failures >= failureThreshold() && circuit.openedAt === undefined) {
65+
circuit.openedAt = Date.now();
66+
upstreamCircuitEventsTotal.inc({ event: "opened" });
67+
}
68+
}
69+
70+
/** @internal tests */
71+
export function resetGNewsCircuitForTests(): void {
72+
circuit.failures = 0;
73+
circuit.openedAt = undefined;
74+
}
75+
1576
function normalizeArticles(data: unknown): Article[] {
1677
if (
1778
data === null ||
@@ -39,6 +100,7 @@ function toProviderParams(options: ArticleSearchOptions): Record<string, string
39100

40101
export class GNewsProvider implements NewsProvider {
41102
async search(options: ArticleSearchOptions): Promise<Article[]> {
103+
assertCircuitAllowsRequest();
42104
const stopUpstreamTimer = upstreamRequestDurationSeconds.startTimer();
43105

44106
try {
@@ -51,12 +113,14 @@ export class GNewsProvider implements NewsProvider {
51113
const articles = normalizeArticles(response.data);
52114
upstreamRequestsTotal.inc({ outcome: "success" });
53115
stopUpstreamTimer({ outcome: "success" });
116+
recordProviderSuccess();
54117
return articles;
55118
} catch (err) {
56119
const outcome =
57120
err instanceof HttpError && err.statusCode === 502 ? "invalid_payload" : "error";
58121
upstreamRequestsTotal.inc({ outcome });
59122
stopUpstreamTimer({ outcome });
123+
recordProviderFailure();
60124
if (axios.isAxiosError(err)) {
61125
throw new HttpError(502, "Upstream news service unavailable", "upstream_unavailable");
62126
}

test/app.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import axios from "axios";
2020
import app from "../src/app";
2121
import { sampleArticles } from "./fixtures/articles";
2222
import { resetCacheStoreForTests, setCacheStoreForTests } from "../src/cache/store";
23+
import { resetGNewsCircuitForTests } from "../src/providers/gnewsProvider";
2324

2425
describe("app", () => {
2526
beforeEach(() => {
2627
mockGet.mockReset();
2728
resetCacheStoreForTests();
29+
resetGNewsCircuitForTests();
2830
});
2931

3032
it("GET /health returns ok", async () => {
@@ -336,10 +338,30 @@ describe("app", () => {
336338
});
337339
expect(typeof res.body.error.requestId).toBe("string");
338340
});
341+
342+
it("opens the upstream circuit after repeated provider failures", async () => {
343+
mockGet.mockRejectedValue(new axios.AxiosError("timeout"));
344+
const q = `circuit-${Math.random().toString(36).slice(2)}`;
345+
346+
await request(app).get(`/api/v1/articles?query=${encodeURIComponent(q)}&count=1`);
347+
await request(app).get(`/api/v1/articles?query=${encodeURIComponent(q)}&count=1`);
348+
await request(app).get(`/api/v1/articles?query=${encodeURIComponent(q)}&count=1`);
349+
const res = await request(app).get(`/api/v1/articles?query=${encodeURIComponent(q)}&count=1`);
350+
351+
expect(res.status).toBe(503);
352+
expect(res.body.error).toMatchObject({
353+
code: "upstream_circuit_open",
354+
message: "Upstream news service temporarily unavailable",
355+
});
356+
expect(mockGet).toHaveBeenCalledTimes(3);
357+
});
339358
});
340359

341360
describe("CLIENT_API_KEYS gate", () => {
342361
beforeEach(() => {
362+
mockGet.mockReset();
363+
resetCacheStoreForTests();
364+
resetGNewsCircuitForTests();
343365
vi.stubEnv("CLIENT_API_KEYS", "secret-one");
344366
});
345367

0 commit comments

Comments
 (0)