fix(backend): route every async handler through the error middleware (#59)
Express 4 does not forward a rejected promise from an async handler, so an unwrapped async route never responds at all — the request hangs until the client gives up, nothing reaches the error middleware, and monitoring sees an open connection rather than a 500. That silence is the shape of the 2026-08-17 incident, where an unhandled rejection left every item query hanging and the storefront rendered it as an empty shop. `asyncRoute` was written in response, but it was only applied to some routes: 30 handlers added afterwards were still bare, including register, login, the whole cart, and PayPal checkout. Wraps all 30, plus two the issue's inventory missed. `attachCustomer` is a bare async middleware mounted globally in app.ts, so a rejection in its session lookup would hang every request in the application — including the 25 handlers that were already wrapped correctly, which meant the guarantee did not actually hold anywhere. The PayPal webhook registers on a second router named `webhookRouter`, so an audit grepping for `router.` walked straight past it. Adds a unit test that scans the route sources and fails on any registration whose handler is not wrapped. A convention already half-forgotten once will be forgotten again, and enforcement is what the issue asked for; ESLint would be the better home for it but there is no ESLint in this repo yet (#60). The test walks parens rather than lines, so it also catches a handler whose `async` sits on its own line, and it matches any `*Router` name rather than just `router` — the two ways the existing bare handlers escaped notice. It is deleted along with `asyncRoute` if the project moves to Express 5, which forwards rejections natively. No behaviour changes on the success path; the failure path turns a hung request into a logged 500. Closes #59 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
||||
|
||||
@@ -93,7 +94,7 @@ async function openCheckout(
|
||||
return { ok: true, checkoutId, cart };
|
||||
}
|
||||
|
||||
router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => {
|
||||
router.post('/paypal/create', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { shippingAddressId } = req.body;
|
||||
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
|
||||
|
||||
@@ -139,7 +140,7 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Returns the sold item ids and the buyer, so the caller can notify favoriters
|
||||
// *after* COMMIT. Sending inside the transaction would email people about a
|
||||
@@ -169,7 +170,7 @@ async function completeCheckout(client: any, checkoutId: number, processor: stri
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/paypal/capture', requireCustomer, async (req: Request, res: Response) => {
|
||||
router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { orderID } = req.body;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -201,9 +202,9 @@ router.post('/paypal/capture', requireCustomer, async (req: Request, res: Respon
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/demo/purchase', requireCustomer, async (req: Request, res: Response) => {
|
||||
router.post('/demo/purchase', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
if (process.env.DEMO_MODE === 'false') return res.status(403).json({ error: 'demo mode disabled' });
|
||||
const { shippingAddressId } = req.body;
|
||||
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
|
||||
@@ -225,9 +226,9 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
webhookRouter.post('/', async (req: Request, res: Response) => {
|
||||
webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, {
|
||||
@@ -272,6 +273,6 @@ webhookRouter.post('/', async (req: Request, res: Response) => {
|
||||
console.error('webhook error', err);
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
export { router, webhookRouter };
|
||||
|
||||
Reference in New Issue
Block a user