Skip to content

Commit 3eb6a40

Browse files
authored
Merge pull request #55 from OlaGreat/add-centralized-error-middleware
feat: add centralized Express error-handling middleware
2 parents 12fc1ca + 9bca168 commit 3eb6a40

2 files changed

Lines changed: 106 additions & 29 deletions

File tree

src/middleware/errorHandler.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { randomUUID } from 'node:crypto';
2+
3+
/**
4+
* Attach a requestId to every incoming request so it flows through
5+
* to error responses and server logs.
6+
*/
7+
export function requestId(req, _res, next) {
8+
req.requestId = randomUUID();
9+
next();
10+
}
11+
12+
/**
13+
* Centralized Express error-handling middleware.
14+
*
15+
* Normalizes all unhandled errors into a consistent JSON envelope:
16+
* { code, message, requestId }
17+
*
18+
* Stack traces are only included in non-production environments and
19+
* are never sent to the client in production.
20+
*/
21+
export function errorHandler(err, req, res, _next) {
22+
const isProd = process.env.NODE_ENV === 'production';
23+
24+
// Determine HTTP status — honour err.status / err.statusCode if set
25+
const status = err.status || err.statusCode || 500;
26+
27+
// Derive a machine-readable code from the error name or a default
28+
const code = err.code || err.name || (status === 404 ? 'NOT_FOUND' : 'INTERNAL_ERROR');
29+
30+
// Always log the full error server-side for diagnostics
31+
console.error({
32+
requestId: req.requestId,
33+
method: req.method,
34+
url: req.originalUrl,
35+
status,
36+
code,
37+
message: err.message,
38+
stack: err.stack,
39+
});
40+
41+
// In production, mask only 5xx server errors to avoid leaking internals.
42+
// 4xx client errors keep their message so API consumers get actionable feedback.
43+
const message = isProd && status >= 500
44+
? 'An unexpected error occurred'
45+
: (err.message || 'An unexpected error occurred');
46+
47+
const body = {
48+
code,
49+
message,
50+
requestId: req.requestId,
51+
};
52+
53+
// Expose stack only in development
54+
if (!isProd && err.stack) {
55+
body.stack = err.stack;
56+
}
57+
58+
res.status(status).json(body);
59+
}

src/server.js

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AGENTS, discoverAgents, getAgentById } from './agents/registry.js';
88
import { runResearch, runSummary, runAnalysis, runCode, setApiKey, MODEL_LABELS } from './agents/services.js';
99
import { orchestrate } from './agents/orchestrator.js';
1010
import { getBalance, getTransactions, sendPayment } from './stellar/wallet.js';
11+
import { requestId, errorHandler } from './middleware/errorHandler.js';
1112

1213
// x402 imports
1314
import { paymentMiddlewareFromConfig } from '@x402/express';
@@ -20,6 +21,7 @@ const app = express();
2021
app.use(cors());
2122
app.use(express.json());
2223
app.use(express.static(path.join(__dirname, '..', 'public')));
24+
app.use(requestId);
2325

2426
// ─── SSE Event Stream ────────────────────────────────────────
2527
const sseClients = [];
@@ -103,117 +105,122 @@ if (config.serverAddress) {
103105
}
104106

105107
// ─── Premium x402-Protected Endpoints ────────────────────────
106-
app.get('/api/premium/research', async (req, res) => {
108+
app.get('/api/premium/research', async (req, res, next) => {
107109
try {
108110
const topic = req.query.topic || 'AI and blockchain payments';
109111
broadcast({ type: 'agent_call', agent: '🔬 Research Agent', agentId: 'research-bot', input: topic, cost: '0.01', timestamp: new Date().toISOString() });
110112
const result = await runResearch(topic);
111113
broadcast({ type: 'agent_response', agent: '🔬 Research Agent', agentId: 'research-bot', resultPreview: result.substring(0, 150), cost: '0.01', timestamp: new Date().toISOString() });
112114
res.json({ agent: 'research-bot', topic, result, model: MODEL_LABELS.research, cost: '0.01 USDC', paidVia: 'x402' });
113115
} catch (err) {
114-
res.status(500).json({ error: 'Research agent temporarily unavailable', details: err.message });
116+
next(err);
115117
}
116118
});
117119

118-
app.get('/api/premium/summarize', async (req, res) => {
120+
app.get('/api/premium/summarize', async (req, res, next) => {
119121
try {
120122
const text = req.query.text || 'Please provide text to summarize via ?text= parameter';
121123
broadcast({ type: 'agent_call', agent: '📝 Summary Agent', agentId: 'summary-bot', input: text.substring(0, 100), cost: '0.01', timestamp: new Date().toISOString() });
122124
const result = await runSummary(text);
123125
broadcast({ type: 'agent_response', agent: '📝 Summary Agent', agentId: 'summary-bot', resultPreview: result.substring(0, 150), cost: '0.01', timestamp: new Date().toISOString() });
124126
res.json({ agent: 'summary-bot', result, model: MODEL_LABELS.summary, cost: '0.01 USDC', paidVia: 'x402' });
125127
} catch (err) {
126-
res.status(500).json({ error: 'Summary agent temporarily unavailable', details: err.message });
128+
next(err);
127129
}
128130
});
129131

130-
app.get('/api/premium/analyze', async (req, res) => {
132+
app.get('/api/premium/analyze', async (req, res, next) => {
131133
try {
132134
const topic = req.query.topic || 'AI agent economies';
133135
broadcast({ type: 'agent_call', agent: '📊 Analysis Agent', agentId: 'analyst-bot', input: topic, cost: '0.05', timestamp: new Date().toISOString() });
134136
const result = await runAnalysis(topic);
135137
broadcast({ type: 'agent_response', agent: '📊 Analysis Agent', agentId: 'analyst-bot', resultPreview: result.substring(0, 150), cost: '0.05', timestamp: new Date().toISOString() });
136138
res.json({ agent: 'analyst-bot', topic, result, model: MODEL_LABELS.analysis, cost: '0.05 USDC', paidVia: 'x402' });
137139
} catch (err) {
138-
res.status(500).json({ error: 'Analysis agent temporarily unavailable', details: err.message });
140+
next(err);
139141
}
140142
});
141143

142-
app.get('/api/premium/code', async (req, res) => {
144+
app.get('/api/premium/code', async (req, res, next) => {
143145
try {
144146
const prompt = req.query.prompt || 'Write a hello world function';
145147
broadcast({ type: 'agent_call', agent: '💻 Code Agent', agentId: 'code-bot', input: prompt.substring(0, 100), cost: '0.03', timestamp: new Date().toISOString() });
146148
const result = await runCode(prompt);
147149
broadcast({ type: 'agent_response', agent: '💻 Code Agent', agentId: 'code-bot', resultPreview: result.substring(0, 150), cost: '0.03', timestamp: new Date().toISOString() });
148150
res.json({ agent: 'code-bot', prompt, result, model: MODEL_LABELS.code, cost: '0.03 USDC', paidVia: 'x402' });
149151
} catch (err) {
150-
res.status(500).json({ error: 'Code agent temporarily unavailable', details: err.message });
152+
next(err);
151153
}
152154
});
153155

154156
// ─── Free Agent Endpoints (for internal orchestrator use) ────
155-
app.get('/api/research', async (req, res) => {
157+
app.get('/api/research', async (req, res, next) => {
156158
try {
157159
const topic = req.query.topic || 'AI payments';
158160
const result = await runResearch(topic);
159161
res.json({ agent: 'research-bot', topic, result, model: MODEL_LABELS.research, cost: '0.01 USDC' });
160162
} catch (err) {
161-
res.status(500).json({ error: 'Agent temporarily unavailable', fallback: 'Try again' });
163+
next(err);
162164
}
163165
});
164166

165-
app.get('/api/summarize', async (req, res) => {
167+
app.get('/api/summarize', async (req, res, next) => {
166168
try {
167169
const text = req.query.text || '';
168170
const result = await runSummary(text);
169171
res.json({ agent: 'summary-bot', result, model: MODEL_LABELS.summary, cost: '0.01 USDC' });
170172
} catch (err) {
171-
res.status(500).json({ error: 'Agent temporarily unavailable', fallback: 'Try again' });
173+
next(err);
172174
}
173175
});
174176

175-
app.get('/api/analyze', async (req, res) => {
177+
app.get('/api/analyze', async (req, res, next) => {
176178
try {
177179
const topic = req.query.topic || '';
178180
const result = await runAnalysis(topic);
179181
res.json({ agent: 'analyst-bot', topic, result, model: MODEL_LABELS.analysis, cost: '0.05 USDC' });
180182
} catch (err) {
181-
res.status(500).json({ error: 'Agent temporarily unavailable', fallback: 'Try again' });
183+
next(err);
182184
}
183185
});
184186

185-
app.get('/api/code', async (req, res) => {
187+
app.get('/api/code', async (req, res, next) => {
186188
try {
187189
const prompt = req.query.prompt || '';
188190
const result = await runCode(prompt);
189191
res.json({ agent: 'code-bot', result, model: MODEL_LABELS.code, cost: '0.03 USDC' });
190192
} catch (err) {
191-
res.status(500).json({ error: 'Agent temporarily unavailable', fallback: 'Try again' });
193+
next(err);
192194
}
193195
});
194196

195197
// ─── Orchestrator Endpoint ───────────────────────────────────
196-
app.post('/api/orchestrate', async (req, res) => {
198+
app.post('/api/orchestrate', async (req, res, next) => {
197199
try {
198200
const { task, budget } = req.body;
199-
if (!task) return res.status(400).json({ error: 'Missing "task" in request body' });
201+
if (!task) {
202+
const err = new Error('Missing "task" in request body');
203+
err.status = 400;
204+
err.code = 'MISSING_FIELD';
205+
return next(err);
206+
}
200207
const budgetNum = parseFloat(budget) || 0.15;
201208
const result = await orchestrate(task, budgetNum, broadcast);
202209
res.json(result);
203210
} catch (err) {
204-
res.status(500).json({ error: 'Orchestrator failed', details: err.message });
211+
next(err);
205212
}
206213
});
207214

208215
// Also support GET for easy testing
209-
app.get('/api/orchestrate', async (req, res) => {
216+
app.get('/api/orchestrate', async (req, res, next) => {
210217
try {
211218
const task = req.query.task || 'Research AI payments';
212219
const budget = parseFloat(req.query.budget) || 0.15;
213220
const result = await orchestrate(task, budget, broadcast);
214221
res.json(result);
215222
} catch (err) {
216-
res.status(500).json({ error: 'Orchestrator failed', details: err.message });
223+
next(err);
217224
}
218225
});
219226

@@ -227,14 +234,19 @@ app.get('/api/agents/discover/:capability', (req, res) => {
227234
res.json(results);
228235
});
229236

230-
app.get('/api/agents/:id', (req, res) => {
237+
app.get('/api/agents/:id', (req, res, next) => {
231238
const agent = getAgentById(req.params.id);
232-
if (!agent) return res.status(404).json({ error: 'Agent not found' });
239+
if (!agent) {
240+
const err = new Error('Agent not found');
241+
err.status = 404;
242+
err.code = 'NOT_FOUND';
243+
return next(err);
244+
}
233245
res.json(agent);
234246
});
235247

236248
// ─── Wallet Endpoints ────────────────────────────────────────
237-
app.get('/api/wallet/balances', async (req, res) => {
249+
app.get('/api/wallet/balances', async (req, res, next) => {
238250
try {
239251
const wallets = {};
240252
if (config.serverAddress) {
@@ -248,18 +260,18 @@ app.get('/api/wallet/balances', async (req, res) => {
248260
}
249261
res.json(wallets);
250262
} catch (err) {
251-
res.status(500).json({ error: 'Failed to fetch balances', details: err.message });
263+
next(err);
252264
}
253265
});
254266

255-
app.get('/api/wallet/transactions', async (req, res) => {
267+
app.get('/api/wallet/transactions', async (req, res, next) => {
256268
try {
257269
const address = req.query.address || config.orchestratorAddress || config.serverAddress;
258270
if (!address) return res.json([]);
259271
const txs = await getTransactions(address, 20);
260272
res.json(txs);
261273
} catch (err) {
262-
res.status(500).json({ error: 'Failed to fetch transactions', details: err.message });
274+
next(err);
263275
}
264276
});
265277

@@ -303,10 +315,13 @@ app.get('/api/config/apikey', (req, res) => {
303315
});
304316
});
305317

306-
app.post('/api/config/apikey', (req, res) => {
318+
app.post('/api/config/apikey', (req, res, next) => {
307319
const { apiKey } = req.body;
308320
if (!apiKey || !apiKey.startsWith('sk-ant-')) {
309-
return res.status(400).json({ error: 'Invalid API key. Must start with sk-ant-' });
321+
const err = new Error('Invalid API key. Must start with sk-ant-');
322+
err.status = 400;
323+
err.code = 'INVALID_API_KEY';
324+
return next(err);
310325
}
311326
setApiKey(apiKey);
312327
res.json({ success: true, masked: `sk-ant-...${apiKey.slice(-6)}` });
@@ -317,6 +332,9 @@ app.get('/', (req, res) => {
317332
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
318333
});
319334

335+
// ─── Centralized Error Handler ───────────────────────────────
336+
app.use(errorHandler);
337+
320338
// ─── Start Server ────────────────────────────────────────────
321339
const PORT = config.port;
322340
app.listen(PORT, () => {

0 commit comments

Comments
 (0)