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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ agentpay-backend/
stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why
`POST /api/v1/settle` drains backend counters without moving funds.

## Server Timeouts

The HTTP listener configures request, header, keep-alive, and inactive socket
timeouts to bound slow or hung connections:

| Environment variable | Default | Description |
| ---------------------- | ------- | ---------------------------------------- |
| `REQUEST_TIMEOUT_MS` | `30000` | Maximum time for a complete request. |
| `HEADERS_TIMEOUT_MS` | `10000` | Maximum time to receive request headers. |
| `KEEPALIVE_TIMEOUT_MS` | `5000` | Idle keep-alive socket lifetime. |

Invalid, zero, negative, or decimal override values fall back to the defaults.
`HEADERS_TIMEOUT_MS` is raised to at least `KEEPALIVE_TIMEOUT_MS` so keep-alive
reuse does not violate Node's timeout invariant.

## Quickstart

Start a local backend on `http://localhost:3001` with the checked-in
Expand Down
66 changes: 65 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from "express";
import type { Server } from "node:http";
import {
installPreRouteMiddleware,
installRequestStateMiddleware,
Expand All @@ -16,6 +17,68 @@ import { createWebhooksRouter } from "./routes/webhooks.js";

const PORT = process.env.PORT ?? 3001;

type ServerTimeouts = {
requestTimeoutMs: number;
headersTimeoutMs: number;
keepAliveTimeoutMs: number;
};

const DEFAULT_SERVER_TIMEOUTS: ServerTimeouts = {
requestTimeoutMs: 30_000,
headersTimeoutMs: 10_000,
keepAliveTimeoutMs: 5_000,
};

function positiveIntegerEnv(
env: NodeJS.ProcessEnv,
key: string,
fallback: number
): number {
const raw = env[key];
if (raw === undefined) return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
}

function resolveServerTimeouts(env: NodeJS.ProcessEnv): ServerTimeouts {
const requestTimeoutMs = positiveIntegerEnv(
env,
"REQUEST_TIMEOUT_MS",
DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs
);
const keepAliveTimeoutMs = positiveIntegerEnv(
env,
"KEEPALIVE_TIMEOUT_MS",
DEFAULT_SERVER_TIMEOUTS.keepAliveTimeoutMs
);
const configuredHeadersTimeoutMs = positiveIntegerEnv(
env,
"HEADERS_TIMEOUT_MS",
DEFAULT_SERVER_TIMEOUTS.headersTimeoutMs
);
const headersTimeoutMs = Math.max(configuredHeadersTimeoutMs, keepAliveTimeoutMs);

return { requestTimeoutMs, headersTimeoutMs, keepAliveTimeoutMs };
}

/**
* Applies bounded HTTP server timeouts to limit slow or hung connections.
*/
function configureServerTimeouts(
server: Server,
env: NodeJS.ProcessEnv = process.env
): ServerTimeouts {
const timeouts = resolveServerTimeouts(env);
server.requestTimeout = timeouts.requestTimeoutMs;
server.headersTimeout = timeouts.headersTimeoutMs;
server.keepAliveTimeout = timeouts.keepAliveTimeoutMs;
server.setTimeout(timeouts.requestTimeoutMs, (socket) => {
socket.destroy();
});
return timeouts;
}

/**
* Composes the AgentPay Express application from route and middleware modules.
*/
Expand Down Expand Up @@ -48,6 +111,7 @@ if (process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts
const server = app.listen(PORT, () => {
console.log(`AgentPay backend listening on port ${PORT}`);
});
configureServerTimeouts(server);

const shutdown = (signal: string) => {
console.log(`Received ${signal}, draining…`);
Expand All @@ -67,4 +131,4 @@ if (process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts
process.on("SIGINT", () => shutdown("SIGINT"));
}

export { app, createApp };
export { app, configureServerTimeouts, createApp, DEFAULT_SERVER_TIMEOUTS };
64 changes: 64 additions & 0 deletions src/server-timeouts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert";
import { createServer } from "node:http";
import { describe, it } from "node:test";
import { configureServerTimeouts, DEFAULT_SERVER_TIMEOUTS } from "./index.js";

void describe("server timeout hardening", () => {
void it("applies safe defaults to request, header, keep-alive, and socket timeouts", () => {
const server = createServer();

configureServerTimeouts(server, {});

assert.strictEqual(server.requestTimeout, DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs);
assert.strictEqual(server.headersTimeout, DEFAULT_SERVER_TIMEOUTS.headersTimeoutMs);
assert.strictEqual(
server.keepAliveTimeout,
DEFAULT_SERVER_TIMEOUTS.keepAliveTimeoutMs
);
assert.strictEqual(server.timeout, DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs);
});

void it("uses positive integer environment overrides", () => {
const server = createServer();

configureServerTimeouts(server, {
REQUEST_TIMEOUT_MS: "45000",
HEADERS_TIMEOUT_MS: "12000",
KEEPALIVE_TIMEOUT_MS: "7000",
});

assert.strictEqual(server.requestTimeout, 45_000);
assert.strictEqual(server.headersTimeout, 12_000);
assert.strictEqual(server.keepAliveTimeout, 7_000);
assert.strictEqual(server.timeout, 45_000);
});

void it("ignores missing, non-integer, zero, and negative overrides", () => {
const server = createServer();

configureServerTimeouts(server, {
REQUEST_TIMEOUT_MS: "0",
HEADERS_TIMEOUT_MS: "12.5",
KEEPALIVE_TIMEOUT_MS: "-1",
});

assert.strictEqual(server.requestTimeout, DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs);
assert.strictEqual(server.headersTimeout, DEFAULT_SERVER_TIMEOUTS.headersTimeoutMs);
assert.strictEqual(
server.keepAliveTimeout,
DEFAULT_SERVER_TIMEOUTS.keepAliveTimeoutMs
);
});

void it("keeps headersTimeout greater than or equal to keepAliveTimeout", () => {
const server = createServer();

configureServerTimeouts(server, {
HEADERS_TIMEOUT_MS: "4000",
KEEPALIVE_TIMEOUT_MS: "9000",
});

assert.strictEqual(server.keepAliveTimeout, 9_000);
assert.strictEqual(server.headersTimeout, 9_000);
});
});