Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions docs/health-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# `GET /api/health/dependencies`

Probes all four external dependencies in parallel and returns a per-system health snapshot.

---

## Endpoint summary

| Property | Value |
|-----------------|--------------------------------------------------------|
| **Method** | `GET` |
| **Path** | `/api/health/dependencies` |
| **Auth** | None |
| **Caching** | None (fresh probe on every request) |
| **Timeout** | 5 s per probe (inherited from `probeAllDependencies`) |

---

## Probed systems

| Key | What is checked |
|----------------|-------------------------------------------------------------|
| `postgres` | `SELECT 1` against the Postgres connection pool |
| `sorobanRpc` | `getLatestLedger()` call to the configured Soroban RPC node |
| `horizon` | HTTP `GET` to the configured Horizon root URL |
| `webhookQueue` | Redis `PING` via the BullMQ connection |

---

## Response codes

| HTTP Status | Meaning |
|-------------|--------------------------------------------------|
| `200 OK` | All four probes return `ok` |
| `207 Multi-Status` | At least one probe is `degraded`, none are `down` |
| `503 Service Unavailable` | At least one probe is `down` |

---

## Response body

```json
{
"status": "ok",
"correlationId": "e2a1c4d7-1234-5678-abcd-ef0123456789",
"checkedAt": "2026-07-24T22:00:00.000Z",
"dependencies": {
"postgres": { "status": "ok", "latencyMs": 3 },
"sorobanRpc": { "status": "ok", "latencyMs": 12 },
"horizon": { "status": "ok", "latencyMs": 8 },
"webhookQueue": { "status": "ok", "latencyMs": 1 }
}
}
```

### Fields

| Field | Type | Description |
|----------------------------------|-------------------------------|--------------------------------------------------------------|
| `status` | `"ok"` \| `"degraded"` \| `"down"` | Composite status — worst single probe wins. |
| `correlationId` | `string` (UUID) | Echoed from `x-correlation-id` header, or generated. |
| `checkedAt` | ISO-8601 string | UTC timestamp of the response. |
| `dependencies.<system>.status` | `"ok"` \| `"degraded"` \| `"down"` | Per-system probe result. |
| `dependencies.<system>.latencyMs`| `number` | Round-trip time for the probe, in milliseconds. |
| `dependencies.<system>.error` | `string` (optional) | Human-readable error detail — only present when `down`. |

---

## Composite status logic

```
all probes "ok" → status "ok"
any probe "down" → status "down"
some "degraded", none "down" → status "degraded"
```

---

## Correlation ID

Pass `x-correlation-id: <id>` in the request header to correlate the response
and server logs with your upstream trace. A UUID is generated automatically when
the header is absent or empty.

---

## Example requests

```bash
# All healthy
curl -s http://localhost:3001/api/health/dependencies | jq .status
# "ok"

# With correlation ID
curl -s http://localhost:3001/api/health/dependencies \
-H "x-correlation-id: my-trace-abc" | jq .correlationId
# "my-trace-abc"
```

---

## Related health endpoints

| Endpoint | Auth | Caching | Purpose |
|----------------------------|------|---------|-------------------------------------------------|
| `GET /health` | None | No | Liveness check — process is up |
| `GET /healthz/dependencies`| None | 5 s TTL | Shallow cached probe (same four systems) |
| `GET /api/health/ready` | None | No | Deep readiness — adds indexer-lag check |
| `GET /api/health/dependencies` | None | No | Full detail, injectable, uncached (this page) |

---

## Security notes

- The endpoint exposes no sensitive data (no credentials, tokens, or user data).
- Restrict access to internal network paths at the infrastructure level (internal ALB, VPC-only routing, security groups) in production.
- The response body deliberately omits internal connection strings or configuration values.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { defaultBodySizeLimitMiddleware, webhookBodySizeLimitMiddleware } from "
import { healthRouter } from "./routes/health";
import dependenciesRouter from "./routes/healthz/dependencies";
import { createReadyRouter } from "./routes/health/ready";
import { dependenciesRouter } from "./routes/health/dependencies";
import { redisConnection } from "./queue";
import { authRouter } from "./routes/auth";
import { marketsRouter } from "./routes/markets";
Expand Down Expand Up @@ -103,6 +104,7 @@ export function createApp(): express.Express {
app.use("/health", healthRouter);
app.use("/healthz/dependencies", dependenciesRouter);
app.use("/api/health/ready", createReadyRouter({ db, redis: redisConnection }));
app.use("/api/health/dependencies", dependenciesRouter);

const mutationMethods = ["POST", "PATCH"] as const;
app.use("/api", (req, res, next) =>
Expand Down
154 changes: 154 additions & 0 deletions src/routes/health/dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* health/dependencies.ts
*
* GET /api/health/dependencies
*
* Probes all external dependencies (Postgres, Soroban RPC, Horizon, webhook
* queue/Redis) and returns a per-system health snapshot with a composite
* status code.
*
* This endpoint complements the existing probes:
* • GET /health — process liveness (instant, no I/O)
* • GET /healthz/dependencies — shallow cached probe (5-second TTL)
* • GET /api/health/ready — deep readiness for orchestrators
* • GET /api/health/dependencies — this file; uncached, injectable, full detail
*
* Response codes
* ──────────────
* 200 OK — all four probes pass ("ok")
* 207 Multi-Status — at least one probe is degraded but none are down
* 503 Unavailable — at least one probe is down
*
* Response shape
* ──────────────
* {
* "status": "ok" | "degraded" | "down",
* "correlationId": "<uuid>",
* "checkedAt": "<ISO-8601>",
* "dependencies": {
* "postgres": { "status": "ok"|"degraded"|"down", "latencyMs": <n>, "error?": "…" },
* "sorobanRpc": { … },
* "horizon": { … },
* "webhookQueue": { … }
* }
* }
*
* Security
* ────────
* No authentication required — the response contains no sensitive data.
* In production, restrict access at the infrastructure level (internal ALB,
* VPC-only routing, etc.).
*
* The response is NOT cached here (unlike /healthz/dependencies). Callers
* that want caching should use the lower-level getCachedDependencyHealth()
* directly or add a cache layer in front of this endpoint.
*
* Injectable dependencies
* ───────────────────────
* All external I/O is encapsulated in the `DependenciesRouterDeps.probeFn`
* callback so tests can substitute a fully-controlled stub without touching
* real infrastructure.
*/

import { Router, Request, Response } from "express";
import { randomUUID } from "crypto";
import { logger } from "../../config/logger";
import {
probeAllDependencies,
computeCompositeStatus,
type DependencyHealth,
type CompositeStatus,
} from "../../services/healthProbes";

// ── Injectable dependency interface ──────────────────────────────────────────

/**
* Signature of the probe function injected into the router.
* Production code passes `probeAllDependencies`; tests pass a stub.
*/
export type ProbeFn = () => Promise<DependencyHealth>;

export interface DependenciesRouterDeps {
/**
* Executes all four dependency probes and returns the full health map.
* Defaults to the production `probeAllDependencies` implementation.
*/
probeFn?: ProbeFn;
}

// ── HTTP status mapping ───────────────────────────────────────────────────────

function compositeToHttpStatus(composite: CompositeStatus): number {
if (composite === "ok") return 200;
if (composite === "degraded") return 207;
return 503;
}

// ── Router factory ────────────────────────────────────────────────────────────

/**
* Creates the /api/health/dependencies router.
*
* @param deps.probeFn - Override the probe function (tests only).
* Defaults to `probeAllDependencies`.
*/
export function createDependenciesRouter(deps: DependenciesRouterDeps = {}): Router {
const probe: ProbeFn = deps.probeFn ?? probeAllDependencies;
const router = Router();

/**
* GET /
*
* Runs all dependency probes and returns the health snapshot.
*/
router.get("/", async (req: Request, res: Response, next) => {
// Honour an inbound x-correlation-id if present; otherwise generate one.
const correlationId =
((req.headers["x-correlation-id"] as string | undefined) ?? "").trim() ||
randomUUID();

const requestStart = Date.now();

try {
const health = await probe();
const composite = computeCompositeStatus(health);
const httpStatus = compositeToHttpStatus(composite);

logger.info(
{
correlationId,
status: composite,
httpStatus,
elapsedMs: Date.now() - requestStart,
postgres: health.postgres.status,
sorobanRpc: health.sorobanRpc.status,
horizon: health.horizon.status,
webhookQueue: health.webhookQueue.status,
},
"health_dependencies_check_complete",
);

res.status(httpStatus).json({
status: composite,
correlationId,
checkedAt: new Date().toISOString(),
dependencies: health,
});
} catch (err) {
// Unexpected error — propagate to the global error handler so the
// standard error envelope is returned.
logger.error(
{ correlationId, err, elapsedMs: Date.now() - requestStart },
"health_dependencies_probe_threw",
);
next(err);
}
});

return router;
}

// ── Default export ────────────────────────────────────────────────────────────

/** Production router instance wired into src/index.ts. */
export const dependenciesRouter = createDependenciesRouter();
Loading