Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,8 @@ PAGINATION_MAX_SCAN=10000
# Valid scopes: transfers:read, transfers:write, users:read, users:write, audit:read
# Example (single line, properly escaped for shell):
# API_TOKENS={"my-secret-token":["transfers:read","transfers:write","users:read","users:write","audit:read"]}

# Health / readiness probes
# Per-dependency time budget for GET /api/health/ready. Kept well below
# REQUEST_TIMEOUT_MS so a hung dependency cannot stall the probe.
HEALTH_CHECK_TIMEOUT_MS=1000
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ When preparing a new release:

### Added

- Dependency-aware readiness diagnostics: `GET /api/health/ready` now probes
store, payments (Stellar), and FX under a per-check timeout
(`HEALTH_CHECK_TIMEOUT_MS`), returns redacted reason codes on failure, and
recovers without a process restart. Liveness (`GET /api/health/live`) stays
process-only so orchestrators do not flap restarts during dependency outages.

- Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass
`?cursor=` (with optional `?order=asc|desc`) to page by an indexed position
instead of a row offset; responses carry a `pageInfo` block with
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,15 @@ The API implements Cache-Control response headers for security and efficiency:

### Health

- `GET /api/health` — service health snapshot (version, uptime, env).
- `GET /api/health/live` — liveness probe.
- `GET /api/health/ready` — readiness probe with dependency checks.
- `GET /api/health` — service health snapshot (version, uptime, env). Not a dependency gate.
- `GET /api/health/live` (also `HEAD`) — liveness probe. Process-only; stays responsive during dependency outages
and exhausted API quotas. Liveness polls do not consume the business API budget. Common security, cache,
parsing, timeout, request-ID and logging middleware still apply. Readiness, health snapshots and business
routes retain the API quota; unmatched methods or subpaths continue through the existing route stack.
- `GET /api/health/ready` — readiness probe with bounded checks for store, payments (Stellar), and FX.
Returns `200` when ready and `503` with redacted reason codes when a dependency is down or times out.
Probes re-run on every request so recovery does not require a restart.
Tune the per-check budget with `HEALTH_CHECK_TIMEOUT_MS` (default `1000`).
- `GET /api/version` — service name and version.

### Rates & quotes
Expand Down
6 changes: 6 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const morgan = require('morgan');

const config = require('./config');
const routes = require('./routes');
const healthController = require('./controllers/healthController');
const securityHeaders = require('./middleware/securityHeaders');
const cacheControl = require('./middleware/cacheControl');
const requestTimeout = require('./middleware/requestTimeout');
Expand Down Expand Up @@ -46,6 +47,11 @@ function createApp() {
}
app.use(requestLogger);

// Process liveness must not consume or depend on the business API budget.
// Express also serves HEAD through this GET route; other paths/methods
// continue through the normal API middleware below.
app.get('/api/health/live', healthController.getLiveness);

// Basic abuse protection on the API surface.
app.use('/api', rateLimit(config.rateLimit));

Expand Down
7 changes: 7 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ const config = {
maxScan: parseInt(process.env.PAGINATION_MAX_SCAN, 10) || 10000,
},


health: {
// Per-dependency budget for readiness probes. Must stay well below the
// request timeout so a hung dependency cannot stall the readiness route.
checkTimeoutMs: parseInt(process.env.HEALTH_CHECK_TIMEOUT_MS, 10) || 1000,
},

apiTokens: (() => {
try {
if (process.env.API_TOKENS) {
Expand Down
30 changes: 21 additions & 9 deletions src/controllers/healthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

const config = require('../config');
const { name, version } = require('../../package.json');
const dependencyHealth = require('../services/dependencyHealthService');

/**
* Health controller.
*
* Liveness answers "is the process up?" and must stay cheap and dependency-
* free so orchestrators do not restart a process that is merely waiting on
* a degraded dependency. Readiness answers "can this instance serve traffic?"
* by running bounded, redacted dependency probes that recover automatically
* once the dependency is healthy again.
*/

/**
* GET /api/health
* Reports basic liveness information.
* Reports basic process information (not a dependency gate).
*/
function getHealth(req, res) {
res.json({
Expand All @@ -36,23 +43,28 @@ function getVersion(req, res) {

/**
* GET /api/health/live
* Liveness probe: confirms the process is up and responding.
* Liveness probe: confirms the process is up and responding. Intentionally
* ignores store / payment / FX state so outages do not flap restarts.
*/
function getLiveness(req, res) {
res.json({ status: 'alive', timestamp: new Date().toISOString() });
}

/**
* GET /api/health/ready
* Readiness probe: confirms dependencies needed to serve traffic are up.
* The demo store is always in-memory, so readiness simply mirrors liveness.
* Readiness probe: bounded checks against store, payments, and FX.
* Returns 200 when every dependency is healthy and 503 otherwise. Reason
* codes are redacted; recovery is automatic on the next successful probe.
*/
function getReadiness(req, res) {
res.json({
status: 'ready',
checks: { store: 'ok' },
async function getReadiness(req, res) {
const result = await dependencyHealth.evaluateReadiness();
const payload = {
status: result.status,
checks: result.checks,
timeoutMs: result.timeoutMs,
timestamp: new Date().toISOString(),
});
};
res.status(result.ready ? 200 : 503).json(payload);
}

module.exports = {
Expand Down
3 changes: 0 additions & 3 deletions src/routes/healthRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ const router = express.Router();
// GET /api/health
router.get('/', asyncHandler(healthController.getHealth));

// GET /api/health/live
router.get('/live', asyncHandler(healthController.getLiveness));

// GET /api/health/ready
router.get('/ready', asyncHandler(healthController.getReadiness));

Expand Down
Loading