Эксперт по Отладке Кода
Находит ошибки в коде и выдаёт исправления с разбором причин, критичности и профилактикой.
Пример запуска
Сгенерировано Claude Opus 5.5, 23 сентября 2026 г.
Значения переменных
- Язык программирования
- JavaScript (Node.js 20)
- Фреймворк и окружение
- Express 4.18, node-postgres (pg) 8.11, Stripe Node SDK 14, PostgreSQL 15, деплой на Render.com (staging-окружение)
- Ожидаемое поведение
- 1) Webhook /api/stripe-webhook должен проверять подпись Stripe, обрабатывать событие payment_intent.succeeded ровно один раз (даже если Stripe присылает повторную доставку того же event.id), помечать заказ оплаченным и отправлять письмо-квитанцию один раз. 2) Соединение с БД должно корректно возвращаться в пул после каждого запроса. 3) GET /api/orders?page=2&limit=10 должен возвращать записи 11-20 без пропусков и дублей относительно page=1.
- Фактическое поведение и ошибка
- Stripe ретраит доставку вебхука (обычная практика при таймауте ответа >3с), и заказ помечается оплаченным по несколько раз, клиенту уходит 2-3 одинаковых письма с квитанцией. В логах Render иногда падает необработанный reject: 'UnhandledPromiseRejectionWarning: Error: sorry, too many clients already' — похоже, соединения pg не освобождаются под нагрузкой. Отдельно на странице заказов: page=1 отдаёт id 1-10, но page=2 отдаёт id 10-19 (id=10 дублируется, id=20 пропадает).
- Шаги воспроизведения
- 1. Запустить сервер локально (node server.js) с проброшенным вебхуком через `stripe listen --forward-to localhost:3000/api/stripe-webhook`. 2. Выполнить `stripe trigger payment_intent.succeeded` дважды подряд для одного и того же payment_intent (через `stripe events resend`). 3. Проверить таблицу orders — статус меняется, письмо в консоли логируется дважды. 4. Дать 50 параллельных запросов через `autocannon -c 50 -d 10 http://localhost:3000/api/orders` — через 10-15 секунд появляется ошибка 'too many clients already'. 5. Вызвать GET /api/orders?page=1&limit=10 и GET /api/orders?page=2&limit=10, сравнить массивы id — обнаружится пересечение и пропуск.
- Код
- const express = require('express'); const { Pool } = require('pg'); const Stripe = require('stripe'); const nodemailer = require('nodemailer'); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const stripe = Stripe(process.env.STRIPE_SECRET_KEY); app.use(express.json()); app.post('/api/stripe-webhook', async (req, res) => { const event = req.body; if (event.type === 'payment_intent.succeeded') { const orderId = event.data.object.metadata.order_id; const client = await pool.connect(); const query = "UPDATE orders SET status = 'paid' WHERE id = " + orderId; client.query(query); const order = await client.query( "SELECT * FROM orders WHERE id = " + orderId ); sendReceiptEmail(order.rows[0]); } res.sendStatus(200); }); async function sendReceiptEmail(order) { const transporter = nodemailer.createTransport({ /* smtp config */ }); await transporter.sendMail({ to: order.customer_email, subject: 'Ваш заказ оплачен', text: `Заказ #${order.id} успешно оплачен на сумму ${order.amount}` }); } app.get('/api/orders', async (req, res) => { const page = req.query.page || 1; const limit = req.query.limit || 10; const offset = page * limit; const result = await pool.query( `SELECT * FROM orders ORDER BY id LIMIT ${limit} OFFSET ${offset}` ); res.json(result.rows); }); app.listen(3000, () => console.log('Server started on port 3000'));
- Исправленный код
- const express = require('express'); const { Pool } = require('pg'); const Stripe = require('stripe'); const nodemailer = require('nodemailer'); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); const stripe = Stripe(process.env.STRIPE_SECRET_KEY); // Важно: raw body нужен для проверки подписи Stripe, express.json() применяем только к остальным маршрутам app.post( '/api/stripe-webhook', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { const signature = req.headers['stripe-signature']; event = stripe.webhooks.constructEvent( req.body, signature, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } if (event.type === 'payment_intent.succeeded') { const orderId = event.data.object.metadata.order_id; const client = await pool.connect(); try { await client.query('BEGIN'); // Идемпотентность: пропускаем уже обработанные event.id const already = await client.query( 'SELECT 1 FROM processed_webhook_events WHERE event_id = $1', [event.id] ); if (already.rowCount > 0) { await client.query('ROLLBACK'); return res.sendStatus(200); } await client.query( 'INSERT INTO processed_webhook_events (event_id, processed_at) VALUES ($1, now())', [event.id] ); const updated = await client.query( "UPDATE orders SET status = 'paid' WHERE id = $1 AND status <> 'paid' RETURNING *", [orderId] ); await client.query('COMMIT'); if (updated.rowCount > 0) { await sendReceiptEmail(updated.rows[0]); } } catch (err) { await client.query('ROLLBACK'); console.error('Webhook processing error:', err); return res.sendStatus(500); } finally { client.release(); } } res.sendStatus(200); } ); app.use(express.json()); async function sendReceiptEmail(order) { const transporter = nodemailer.createTransport({ /* smtp config */ }); await transporter.sendMail({ to: order.customer_email, subject: 'Ваш заказ оплачен', text: `Заказ #${order.id} успешно оплачен на сумму ${order.amount}` }); } app.get('/api/orders', async (req, res) => { const page = Math.max(1, parseInt(req.query.page, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 10)); const offset = (page - 1) * limit; const result = await pool.query( 'SELECT * FROM orders ORDER BY id LIMIT $1 OFFSET $2', [limit, offset] ); res.json(result.rows); }); app.listen(3000, () => console.log('Server started on port 3000'));
- Уровень детализации
- Высокий: построчный разбор, объяснение каждой первопричины, готовые фрагменты кода и рекомендации по тестам/мониторингу
Краткий вердикт
Письма дублируются, потому что обработчик вебхука не идемпотентен. Он не запоминает обработанный event.id, не проверяет, был ли заказ уже оплачен, и отправляет письмо безусловно. Кроме того, каждый вызов вебхука навсегда забирает соединение из пула: pool.connect() вызывается без client.release(). После ~10 вызовов пул исчерпывается, запросы зависают, Stripe получает таймауты и присылает событие повторно. Пагинация сломана формулой offset = page * limit, а оба эндпоинта уязвимы к SQL-инъекциям. Вебхук вдобавок принимает неподписанные запросы, так что любой может отметить любой заказ оплаченным.
Два симптома из описания не выводятся из присланного кода: «10–19» на page=2 и текст UnhandledPromiseRejectionWarning на Node 20. Разбор и список нужных данных — в разделе «Что нельзя установить по коду».
0. Логическое воспроизведение
Сценарий A: вебхук и повторная доставка
| Шаг | Что делает код | Что должно быть | Расхождение |
|---|---|---|---|
| 1 | app.use(express.json()) парсит тело во всех маршрутах |
Для вебхука нужно сырое тело (Buffer), иначе подпись не проверить |
Сырое тело потеряно; подпись и не проверяется |
| 2 | const event = req.body, доверие любому JSON |
stripe.webhooks.constructEvent(raw, sig, secret) |
Первое расхождение: источник события не проверен |
| 3 | event.data.object.metadata.order_id |
Проверить наличие и формат | stripe trigger payment_intent.succeeded создаёт PaymentIntent без metadata.order_id, получается undefined |
| 4 | await pool.connect() |
Взять клиента и гарантированно вернуть | Клиент не возвращается никогда |
| 5 | client.query("UPDATE ... WHERE id = " + orderId) без await |
Дождаться результата внутри транзакции | При orderId = undefined SQL WHERE id = undefined падает с column "undefined" does not exist, и этот промис никто не обрабатывает |
| 6 | await client.query("SELECT ...") |
— | node-postgres ставит запросы одного клиента в очередь, поэтому SELECT увидит результат UPDATE. Если UPDATE упал, SELECT вернёт строку в старом состоянии |
| 7 | sendReceiptEmail(order.rows[0]) без await и без проверки |
Отправить один раз, только при реальной смене статуса | Второе расхождение: письмо уходит при каждой доставке. Если строки нет, order.customer_email даёт TypeError и ещё один необработанный reject |
| 8 | res.sendStatus(200) |
Быстрый 2xx | Пока пул жив, ответ быстрый. Когда пул исчерпан (шаг 4), pool.connect() висит бесконечно (нет connectionTimeoutMillis), Stripe получает таймаут и ретраит |
При stripe events resend <evt_id> приходит тот же event.id. Код его не запоминает, поэтому UPDATE выполняется повторно (безвредно, но без смысла), а письмо уходит повторно (это и есть баг).
Сценарий B: пагинация
req.query.page и req.query.limit — строки. Умножение приводит их к числам: "1" * "10" = 10.
| Запрос | offset в коде | Что вернёт код (при id 1..N без пропусков) | Ожидалось |
|---|---|---|---|
page=1&limit=10 |
10 | id 11–20 | id 1–10 |
page=2&limit=10 |
20 | id 21–30 | id 11–20 |
Код систематически пропускает первую страницу. Наблюдаемое «page=1 → 1–10, page=2 → 10–19» этим кодом не воспроизводится. Гипотезы ниже, в проблеме #8.
Сценарий C: autocannon на /api/orders
pool.query() сам берёт и возвращает клиента, поэтому этот маршрут соединения не течёт. Пул по умолчанию ограничен max: 10 на процесс. Один процесс с этим кодом не может открыть к Postgres больше 10 соединений, а ошибка sorry, too many clients already приходит от сервера Postgres при превышении max_connections. Значит, соединения открывает кто-то ещё: несколько инстансов или процессов, другие сервисы на той же БД, зависшие сессии после падений. Утечка в вебхуке даёт другой симптом: зависание запросов в очереди пула. Подробности — в #12.
Найдено проблем: 16
#1 — Безопасность: нет проверки подписи Stripe · Критичность: Критическая
- Расположение:
app.use(express.json())и первая строка обработчика/api/stripe-webhook. - Причина: обработчик верит любому POST. Одного
curl -X POST .../api/stripe-webhook -d '{"type":"payment_intent.succeeded","data":{"object":{"metadata":{"order_id":"42"}}}}'достаточно, чтобы отметить заказ оплаченным без оплаты. ПодписьStripe-Signatureвычисляется по байтам тела. Послеexpress.json()байты потеряны, иJSON.stringify(req.body)их не восстановит: порядок ключей, пробелы и unicode-экранирование могут отличаться. Поэтомуexpress.raw()должен стоять до глобальногоexpress.json()или только на этом маршруте. - Исправление:
// Маршрут вебхука регистрируется ДО app.use(express.json()) app.post( '/api/stripe-webhook', express.raw({ type: 'application/json', limit: '1mb' }), asyncHandler(async (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, // Buffer, не объект req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET // whsec_... ); } catch (err) { console.warn('Stripe signature verification failed:', err.message); return res.status(400).send('Invalid signature'); } // ... обработка }) ); app.use(express.json()); // для всех остальных маршрутов - Профилактика: интеграционный тест должен ожидать 400 для неподписанного и испорченного тела и 200 для тела, подписанного через
stripe.webhooks.generateTestHeaderString. Учтите, чтоstripe listenвыдаёт свойwhsec_..., отличный от секрета эндпоинта в Dashboard. Держите их в разных env-переменных для local и staging.
#2 — Логика/конкурентность: нет идемпотентности · Критичность: Высокая
-
Расположение: весь блок
if (event.type === 'payment_intent.succeeded'). -
Причина: Stripe гарантирует доставку at-least-once, так что дубли — штатная ситуация. Код не хранит
event.idи не проверяет, изменил ли UPDATE хоть что-то. Письмо отправляется при каждой доставке. Дубли бывают двух видов, и нужны оба уровня защиты:- Тот же
event.id(ретрай,stripe events resend) закрывается таблицей обработанных событий сPRIMARY KEY. - Разные события о той же оплате (редко, но возможно) закрываются условием
WHERE status IS DISTINCT FROM 'paid'иUNIQUE (order_id, kind)в outbox писем.
Вставка должна быть атомарной:
INSERT ... ON CONFLICT DO NOTHING RETURNING. Схема «сначала SELECT, потом INSERT» даёт гонку: два параллельных запроса оба видят «ещё нет». - Тот же
-
Исправление:
-- миграция CREATE TABLE IF NOT EXISTS processed_webhook_events ( event_id text PRIMARY KEY, event_type text NOT NULL, processed_at timestamptz NOT NULL DEFAULT now() );const outcome = await withTransaction(async (client) => { const inserted = await client.query( `INSERT INTO processed_webhook_events (event_id, event_type) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`, [event.id, event.type] ); if (inserted.rowCount === 0) return { status: 'duplicate_event' }; const updated = await client.query( `UPDATE orders SET status = 'paid' WHERE id = $1 AND status IS DISTINCT FROM 'paid' RETURNING id, amount, customer_email`, [orderId] ); if (updated.rowCount === 0) return { status: 'already_paid_or_missing' }; // письмо ставится в очередь в ТОЙ ЖЕ транзакции (см. #10) await client.query( `INSERT INTO email_outbox (order_id, kind, payload) VALUES ($1, 'receipt', $2) ON CONFLICT (order_id, kind) DO NOTHING`, [updated.rows[0].id, JSON.stringify(updated.rows[0])] ); return { status: 'paid' }; });Почему это корректно при параллельных доставках: второй
INSERTс тем жеevent_idблокируется на уникальном индексе, пока первая транзакция не завершится. После COMMIT первой срабатываетDO NOTHING. Если первая откатилась, вторая спокойно продолжает.IS DISTINCT FROMвместо<>нужен, потому чтоNULL <> 'paid'даётNULL, а неtrue, и заказ сstatus = NULLникогда бы не обновился. -
Профилактика: тест «одно подписанное событие, 5 параллельных POST через
Promise.all». Ожидается ровно одна строка вprocessed_webhook_events, одна вemail_outbox, все ответы 200.
#3 — Утечка ресурсов: клиент пула не освобождается · Критичность: Высокая
- Расположение:
const client = await pool.connect();в вебхуке. - Причина:
client.release()не вызывается ни на успешном пути, ни при ошибке. Каждый вызов вебхука навсегда занимает слот пула (по умолчаниюmax: 10). После 10 вызовов любойpool.connect()иpool.query()ждёт в очереди бесконечно, потому что по умолчаниюconnectionTimeoutMillis: 0. Приложение перестаёт отвечать, а Stripe, не получив ответа, ретраит, что ещё и усиливает дубли. - Исправление: всё, что работает с выделенным клиентом, проходит через одну функцию с
finally:
Если выделенный клиент не нужен (одиночный запрос), используйтеasync function withTransaction(fn) { const client = await pool.connect(); let brokenConnectionError; try { await client.query('BEGIN'); const result = await fn(client); await client.query('COMMIT'); return result; } catch (err) { try { await client.query('ROLLBACK'); } catch (rollbackErr) { // соединение сломано и не должно вернуться в пул brokenConnectionError = rollbackErr; } throw err; } finally { // release(err) с truthy-аргументом уничтожает клиента, а не возвращает его в пул client.release(brokenConnectionError); } }pool.query(): он освобождает клиента сам. - Профилактика: в тестах после N вызовов проверять
pool.totalCount - pool.idleCount === 0иpool.waitingCount === 0. В проде периодически логироватьpool.totalCount / idleCount / waitingCountи алертить наwaitingCount > 0дольше 30 с. Правило для code review: каждыйpool.connect()должен иметьrelease()вfinally.
#4 — Runtime/логика: client.query без await, нет транзакции · Критичность: Высокая
- Расположение:
client.query(query);(UPDATE). - Причина: промис UPDATE никто не ждёт и не обрабатывает. Ошибка UPDATE (как в
WHERE id = undefined) становится unhandled rejection, а в Node 20 это по умолчанию падение процесса (--unhandled-rejections=throw). Порядок выполнения при этом не ломается: node-postgres последовательно выполняет запросы одного клиента. Ломается обработка ошибок. К тому же UPDATE и последующие действия не атомарны: при сбое между ними получится «оплачено, но письмо не поставлено» или наоборот. - Исправление: все запросы через
awaitвнутриwithTransaction(#3). SELECT после UPDATE не нужен, так какRETURNINGвозвращает строку за один запрос (#2). - Профилактика: ESLint-правила
@typescript-eslint/no-floating-promises(работает и для JS сcheckJs) илиpromise/catch-or-returnиrequire-await.
#5 — Исключения: async-обработчики в Express 4 без обработки ошибок · Критичность: Высокая
- Расположение: оба маршрута (
async (req, res) => {...}). - Причина: Express 4 не перехватывает reject промиса, возвращённого обработчиком (это появилось только в Express 5). Любая ошибка
pool.connect()илиpool.query(), включаяtoo many clients already, превращается в unhandled rejection. Ответ клиенту не отправляется (запрос висит до таймаута), а Node 20 завершает процесс. Под нагрузкой autocannon это выглядит как лавина ошибок и рестарты инстанса на Render. - Исправление:
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // последним middleware app.use((err, req, res, next) => { console.error(`[${req.method} ${req.originalUrl}]`, err); if (res.headersSent) return next(err); res.status(500).json({ error: 'Internal Server Error' }); }); - Профилактика: тест, где
pool.queryзамокан на reject: ожидается 500 и живой процесс. Альтернатива —express-async-errorsили переход на Express 5.
#6 — Безопасность: SQL-инъекция в вебхуке · Критичность: Высокая
- Расположение:
"UPDATE orders SET status = 'paid' WHERE id = " + orderIdи SELECT. - Причина: значение из тела запроса конкатенируется в SQL. Без проверки подписи (#1) атакующий полностью контролирует
metadata.order_id. Например,"1 OR 1=1"пометит оплаченными все заказы. Запрос без параметров идёт через simple query protocol, который допускает несколько операторов через;. - Исправление: только параметризованные запросы (
$1) плюс валидация формата:function parseOrderId(raw) { // предполагаю целочисленный serial/bigserial id (из примера "id 1-10"). // Если id — uuid, замените проверку на регулярку uuid. if (typeof raw !== 'string' || !/^[1-9]\d{0,17}$/.test(raw)) return null; return raw; // строкой: безопасно для bigint, pg приведёт тип сам } - Профилактика: запрет конкатенации и шаблонных строк внутри
.query((правило ESLintno-restricted-syntaxнаTemplateLiteralиBinaryExpressionв аргументеquery), семгреп-правило в CI.
#7 — Безопасность: SQL-инъекция в GET /api/orders · Критичность: Критическая
- Расположение:
`SELECT * FROM orders ORDER BY id LIMIT ${limit} OFFSET ${offset}`. - Причина:
limitподставляется из query-строки как есть.?limit=1;DROP TABLE orders;--приводит к выполнению двух операторов, потому чтоpool.query(text)без массива параметров использует simple protocol и разрешает мульти-стейтменты.offsetпри нечисловомpageстанетNaN, ноlimitопасен сам по себе. Эндпоинт публичный, без аутентификации. - Исправление:
С массивом параметров pg использует extended protocol, где несколько операторов невозможны в принципе.const { rows } = await pool.query( 'SELECT id, status, amount, customer_email FROM orders ORDER BY id LIMIT $1 OFFSET $2', [limit, offset] ); - Профилактика: тест
GET /api/orders?limit=1;SELECT%201должен вернуть 400. Тот же lint-запрет, что в #6.
#8 — Логика: неверная формула offset · Критичность: Высокая
-
Расположение:
const offset = page * limit;. -
Причина: страницы нумеруются с 1, поэтому смещение равно
(page - 1) * limit. Текущая формула пропускает первую страницу целиком (см. таблицу в разделе 0). Важно: описанный симптом («page=2 → id 10–19», id 10 дублируется, 20 пропадает) из этого кода не получается. Он соответствует смещению на 1 строку. Самые вероятные объяснения, которые нужно проверить на staging:- На Render задеплоена другая версия кода, например курсорная пагинация с
WHERE id >= $lastIdвместо>. Это даёт ровно «последний id предыдущей страницы повторяется, последний id текущей теряется». - Клиент (фронтенд) передаёт не
page, а смещение или курсор с off-by-one. ORDER BYв задеплоенной версии идёт по неуникальному столбцу (например,created_at). Тогда порядок между страницами не детерминирован и дубли и пропуски случайны.
Кроме того, «записи 11–20» — это позиции, а не значения id. При пропусках в последовательности (удаления, откаты транзакций,
serialне заполняет «дыры») id на странице 2 не обязаны быть 11..20. - На Render задеплоена другая версия кода, например курсорная пагинация с
-
Исправление:
const offset = (page - 1) * limit;Плюс валидация из #9 и опциональный keyset-режим из #16.
-
Профилактика: тест «засеять 25 строк; page1 ∩ page2 = ∅; page1 ∪ page2 = первые 20 по
ORDER BY id; page3 имеет длину 5». Чтобы проверить гипотезу 1, сравните commit SHA в Render Dashboard (Events → Deploy) с локальнымgit rev-parse HEADи включите на staginglog_min_duration_statement = 0на время отладки, чтобы увидеть фактический SQL.
#9 — Крайние случаи: нет валидации page/limit · Критичность: Средняя
- Расположение:
req.query.page || 1,req.query.limit || 10. - Причина:
page=abcдаётoffset = NaN, SQLOFFSET NaNпадает, и вместе с #5 это unhandled rejection.page=0илиpage=-5дают отрицательный OFFSET, это ошибка Postgres.limit=10000000позволяет выгрузить всю таблицу, это DoS и утечка данных.?page=1&page=2превращаетreq.query.pageв массив (парсерqs).limit=0из-за||превращается в 10, что само по себе неочевидно.
- Исправление:
function parseIntParam(raw, { def, min, max }) { if (raw === undefined || raw === '') return def; if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null; const n = Number(raw); if (!Number.isSafeInteger(n) || n < min || n > max) return null; return n; } const limit = parseIntParam(req.query.limit, { def: 10, min: 1, max: 100 }); const page = parseIntParam(req.query.page, { def: 1, min: 1, max: 100000 }); if (limit === null || page === null) { return res.status(400).json({ error: 'page and limit must be positive integers (limit ≤ 100)' }); } - Профилактика: табличный (parametrized) тест на значения
abc,-1,0,1.5,1e3, массив и101: все дают 400.
#10 — Надёжность: отправка письма fire-and-forget и вне транзакции · Критичность: Высокая
- Расположение:
sendReceiptEmail(order.rows[0]);и сама функция. - Причина:
- Вызов без
awaitи без.catch: ошибка SMTP становится unhandled rejection и роняет процесс. order.rows[0]бываетundefined(заказ не найден), тогдаorder.customer_emailбросаетTypeError.nodemailer.createTransport()создаётся на каждое письмо: новое TCP/TLS-соединение и никакого пулинга.- Письмо не связано с фактом оплаты атомарно. Если отправлять синхронно внутри вебхука, SMTP-задержка тянет время ответа, Stripe получает таймаут и ретраит (источник дублей). Если отправлять после COMMIT, но до ответа, и SMTP упал, событие уже отмечено обработанным, ретрай его пропустит, и письмо потеряется навсегда.
- Вызов без
- Исправление: паттерн Transactional Outbox. Письмо ставится в очередь в той же транзакции, что и смена статуса (#2). Вебхук сразу отвечает 200, а отдельный воркер отправляет письма с lease-блокировкой. Во время SMTP-вызова воркер не держит соединение с БД.
CREATE TABLE IF NOT EXISTS email_outbox ( id bigserial PRIMARY KEY, order_id bigint NOT NULL, kind text NOT NULL, payload jsonb NOT NULL, attempts int NOT NULL DEFAULT 0, locked_until timestamptz, last_error text, sent_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (order_id, kind) -- второй рубеж: одна квитанция на заказ ); CREATE INDEX IF NOT EXISTS email_outbox_pending_idx ON email_outbox (id) WHERE sent_at IS NULL;
Честная оговорка: «ровно один раз» для SMTP в строгом смысле недостижимо. Если процесс упадёт между успешнымconst transporter = nodemailer.createTransport({ /* smtp config */ pool: true }); async function processEmailOutbox() { const { rows } = await pool.query( `UPDATE email_outbox SET locked_until = now() + interval '2 minutes', attempts = attempts + 1 WHERE id IN ( SELECT id FROM email_outbox WHERE sent_at IS NULL AND attempts < 5 AND (locked_until IS NULL OR locked_until < now()) ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED) RETURNING id, payload` ); for (const row of rows) { try { await transporter.sendMail(buildReceipt(row.payload)); await pool.query('UPDATE email_outbox SET sent_at = now(), locked_until = NULL WHERE id = $1', [row.id]); } catch (err) { await pool.query('UPDATE email_outbox SET last_error = $2, locked_until = NULL WHERE id = $1', [row.id, String(err && err.message).slice(0, 500)]); } } }sendMailиUPDATE ... sent_at, письмо уйдёт повторно после истечения lease. Outbox сводит окно дубля к этим миллисекундам и гарантирует, что письмо не потеряется. Дубли из-за ретраев Stripe при этом исключены полностью. - Профилактика: тест «SMTP-мок падает на первой попытке»: ожидается
attempts = 1,sent_at IS NULL, после второго прогонаsent_atзаполнен. Метрика и алерт наcount(*) WHERE sent_at IS NULL AND created_at < now() - interval '10 minutes'и наattempts >= 5.
#11 — Крайние случаи: нет metadata.order_id, заказ не найден · Критичность: Средняя
- Расположение:
event.data.object.metadata.order_id. - Причина: шаг 2 вашего сценария (
stripe trigger payment_intent.succeeded) создаёт PaymentIntent без вашей метаданной. В исходном коде это даётWHERE id = undefined, SQL-ошибку и падение. В реальных данных такое тоже бывает: PaymentIntent создан не вашим кодом, например вручную в Dashboard. Отвечать 500 на такие события бессмысленно: Stripe будет ретраить их несколько дней, а результат не изменится. - Исправление:
Для воспроизведения с корректными данными передайте метаданные:const orderId = parseOrderId(event.data.object?.metadata?.order_id); if (orderId === null) { console.error('payment_intent.succeeded without valid metadata.order_id', { eventId: event.id, paymentIntent: event.data.object?.id, }); return res.sendStatus(200); // ack: повтор не поможет; нужен алерт, а не ретрай }stripe trigger payment_intent.succeeded --add payment_intent:metadata.order_id=42. Точный синтаксис--add/--overrideсверьте сstripe trigger --helpвашей версии CLI. - Профилактика: тест подписанного события без
metadataожидает 200, отсутствие изменений в БД и запись в лог. На staging настройте алерт на эту строку лога.
#12 — Ресурсы/конфигурация: пул не настроен; причина «too many clients» · Критичность: Средняя (диагноз частично требует данных)
- Расположение:
new Pool({ connectionString }). - Причина:
- Нет
connectionTimeoutMillis: при исчерпании пула запросы ждут бесконечно, а не падают быстро. - Нет
pool.on('error'): ошибка на простаивающем клиенте (рестарт или failover БД, обрыв сети) генерирует событие'error'без обработчика и роняет процесс. - Нет
statement_timeout: медленный запрос держит соединение сколько угодно. maxне согласован сmax_connectionsсервера. Нужно обеспечитьинстансы × max + прочие клиенты < max_connections − superuser_reserved_connections. На Render число доступных соединений зависит от плана Postgres. Точное значение проверьте черезSHOW max_connections;.
- Нет
- Исправление:
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: Number(process.env.PG_POOL_MAX) || 10, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, // быстрый отказ вместо вечного ожидания statement_timeout: 10_000, // серверный таймаут запроса }); pool.on('error', (err) => console.error('Unexpected idle pg client error', err)); - Что нужно для точного диагноза (см. последний раздел): вывод запроса ниже в момент ошибки, число инстансов сервиса на Render и список других сервисов, подключённых к той же БД.
Чтобы различать свои соединения, задайтеSELECT application_name, client_addr, state, count(*) FROM pg_stat_activity WHERE datname = current_database() GROUP BY 1, 2, 3 ORDER BY 4 DESC;application_nameв строке подключения (?application_name=orders-api) или в конфиге Pool. - Профилактика: периодический лог метрик пула, алерт на
numbackendsизpg_stat_databaseвыше 80% отmax_connections. При многих инстансах поставьте PgBouncer в режиме transaction pooling.
#13 — Логика платежей: нет сверки суммы и допустимых переходов статуса · Критичность: Средняя
- Расположение: UPDATE заказа в вебхуке.
- Причина: заказ помечается оплаченным по одному факту
payment_intent.succeededс егоorder_id. Сумма и валюта не сверяются. Если PaymentIntent может быть создан с произвольной суммой (например, клиентская логика или повторное использованиеorder_id), заказ на 10 000 «оплачивается» платежом на 1. Также не ограничены переходы: заказ в статусеcancelled/refunded(если такие есть) будет перезаписан наpaid. Схему статусов и единицыorders.amountпо коду установить нельзя, поэтому в итоговый код проверка включена в виде помеченного TODO. - Исправление (адаптировать под схему):
// Предположение: orders.amount хранится в минимальных единицах валюты (копейки/центы) // и в orders есть столбец currency. Если нет — скорректируйте. const pi = event.data.object; const updated = await client.query( `UPDATE orders SET status = 'paid' WHERE id = $1 AND status = 'pending' AND amount = $2 AND currency = $3 RETURNING id, amount, customer_email`, [orderId, pi.amount_received, pi.currency] ); - Профилактика: тест «событие с суммой меньше суммы заказа» ожидает, что статус не меняется и появляется алерт. Храните
payment_intent.idв заказе (столбец сUNIQUE) для аудита и сверки.
#14 — Безопасность данных: SELECT *, PII, нет авторизации · Критичность: Средняя (требует проверки)
- Расположение:
GET /api/orders. - Причина: в присланном коде эндпоинт отдаёт все заказы всех клиентов вместе с
customer_emailбез аутентификации. Возможно, защита стоит перед приложением (прокси, отдельный middleware), но в коде её нет, и проверить это нельзя.SELECT *отдаёт наружу любые столбцы, которые добавят в таблицу позже. - Исправление: явный список столбцов (в итоговом коде: известные из исходника
id, status, amount, customer_email) и middleware аутентификации с фильтромWHERE customer_id = $current_userдля клиентского API. - Профилактика: тест «запрос без токена возвращает 401». Схема ответа через DTO или сериализатор, а не сырые строки БД.
#15 — Эксплуатация: порт, graceful shutdown, проверка env · Критичность: Низкая
- Расположение:
app.listen(3000, ...), инициализация. - Причина:
- Render передаёт порт через
process.env.PORT. Жёсткий3000работает только благодаря автоопределению порта, что хрупко. - Нет обработки
SIGTERM: при деплое Render останавливает инстанс, и незавершённые запросы и транзакции обрываются, а соединения закрываются некорректно. - Отсутствие
STRIPE_WEBHOOK_SECRETилиDATABASE_URLобнаружится только на первом запросе. - Без явного
apiVersionформа объектов событий зависит от версии API аккаунта и SDK.
- Render передаёт порт через
- Исправление: см. итоговый код:
requireEnv,process.env.PORT || 3000, обработчикSIGTERMсserver.close()иpool.end(),apiVersionчерез env. - Профилактика: smoke-тест старта без env ожидает немедленное падение с понятным сообщением. Health-check эндпоинт
GET /healthzсSELECT 1.
#16 — Производительность/стабильность: OFFSET-пагинация · Критичность: Низкая
- Расположение:
LIMIT ... OFFSET .... - Причина: Postgres читает и отбрасывает
OFFSETстрок, поэтому глубокие страницы работают за O(offset). Если между запросами page=1 и page=2 удаляется строка с первой страницы, одна запись пропадает: всё сдвигается на 1. ПриORDER BY idвставки новых строк (с большими id) первые страницы не сдвигают, а удаления сдвигают. - Исправление: keyset-пагинация, опционально и обратно совместимо:
// GET /api/orders?after_id=10&limit=10 → id > 10, строго «>», не «>=» const { rows } = await pool.query( 'SELECT id, status, amount, customer_email FROM orders WHERE id > $1 ORDER BY id LIMIT $2', [afterId, limit] ); - Профилактика: тест «удалить строку из первой страницы между запросами»: keyset не теряет записи.
Замечания к черновику исправления в шаблоне
В шаблоне приведён черновой вариант фикса. Он правильный по направлению (raw body, подпись, параметры, (page - 1), finally release), но в нём остались дефекты:
- Гонка SELECT → INSERT в идемпотентности. Два параллельных запроса оба видят «нет записи». Нужен
INSERT ... ON CONFLICT DO NOTHING RETURNING(#2). - Потеря письма.
sendReceiptEmailвызывается после COMMIT. Если SMTP упадёт,catchвернёт 500, Stripe повторит доставку, ноevent.idуже записан, и письмо не уйдёт никогда. Кроме того, SMTP внутри запроса увеличивает время ответа, а это и есть триггер ретраев (#10). pool.connect()внеtry. Ошибка подключения становится unhandled rejection, и Express 4 её не поймает (#5).ROLLBACKвcatchможет сам бросить, если соединение умерло. Исходная ошибка теряется, а сломанный клиент возвращается в пул. Нуженclient.release(err)(#3).ROLLBACKпосле успешного COMMIT (ошибка в письме) даёт лишний запрос и предупреждениеthere is no transaction in progress.status <> 'paid'не сработает приstatus IS NULL(#2).- Нет проверки
metadata.order_id(#11),pool.on('error')и таймаутов (#12), нет обработки ошибок в/api/orders(#5).
Итоговый исправленный код
'use strict';
const express = require('express');
const { Pool } = require('pg');
const Stripe = require('stripe');
const nodemailer = require('nodemailer');
// ---------- Конфигурация и проверка окружения ----------
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`Missing required env variable: ${name}`);
process.exit(1);
}
return value;
}
const DATABASE_URL = requireEnv('DATABASE_URL');
const STRIPE_SECRET_KEY = requireEnv('STRIPE_SECRET_KEY');
const STRIPE_WEBHOOK_SECRET = requireEnv('STRIPE_WEBHOOK_SECRET');
const PORT = Number(process.env.PORT) || 3000;
const stripe = Stripe(STRIPE_SECRET_KEY, {
// Зафиксируйте версию API, под которую написан код:
...(process.env.STRIPE_API_VERSION ? { apiVersion: process.env.STRIPE_API_VERSION } : {}),
});
// ---------- База данных ----------
const pool = new Pool({
connectionString: DATABASE_URL,
max: Number(process.env.PG_POOL_MAX) || 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
statement_timeout: 10_000,
application_name: 'orders-api',
});
pool.on('error', (err) => {
console.error('Unexpected error on idle pg client', err);
});
async function withTransaction(fn) {
const client = await pool.connect();
let brokenConnectionError;
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
try {
await client.query('ROLLBACK');
} catch (rollbackErr) {
brokenConnectionError = rollbackErr;
console.error('ROLLBACK failed, destroying client', rollbackErr);
}
throw err;
} finally {
client.release(brokenConnectionError);
}
}
// ---------- Почта (Transactional Outbox) ----------
const transporter = nodemailer.createTransport({
/* smtp config */
pool: true,
});
function buildReceipt(order) {
// NB: если amount хранится в минимальных единицах — отформатируйте перед выводом.
return {
to: order.customer_email,
subject: 'Ваш заказ оплачен',
text: `Заказ #${order.id} успешно оплачен на сумму ${order.amount}`,
};
}
let outboxRunning = false;
async function processEmailOutbox() {
if (outboxRunning) return;
outboxRunning = true;
try {
const { rows } = await pool.query(
`UPDATE email_outbox
SET locked_until = now() + interval '2 minutes',
attempts = attempts + 1
WHERE id IN (
SELECT id FROM email_outbox
WHERE sent_at IS NULL
AND attempts < 5
AND (locked_until IS NULL OR locked_until < now())
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED)
RETURNING id, payload`
);
for (const row of rows) {
try {
if (!row.payload || !row.payload.customer_email) {
throw new Error('Outbox payload has no customer_email');
}
await transporter.sendMail(buildReceipt(row.payload));
await pool.query(
'UPDATE email_outbox SET sent_at = now(), locked_until = NULL, last_error = NULL WHERE id = $1',
[row.id]
);
} catch (err) {
console.error(`Failed to send outbox email id=${row.id}`, err);
await pool.query(
'UPDATE email_outbox SET locked_until = NULL, last_error = $2 WHERE id = $1',
[row.id, String(err && err.message).slice(0, 500)]
);
}
}
} finally {
outboxRunning = false;
}
}
function kickOutbox() {
processEmailOutbox().catch((err) => console.error('Outbox processing failed', err));
}
const outboxTimer = setInterval(kickOutbox, 5_000);
// ---------- Утилиты ----------
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
function parseOrderId(raw) {
// Предполагается целочисленный id. Для uuid замените проверку.
if (typeof raw !== 'string' || !/^[1-9]\d{0,17}$/.test(raw)) return null;
return raw;
}
function parseIntParam(raw, { def, min, max }) {
if (raw === undefined || raw === '') return def;
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null;
const n = Number(raw);
if (!Number.isSafeInteger(n) || n < min || n > max) return null;
return n;
}
// ---------- Приложение ----------
const app = express();
// Вебхук регистрируется ДО express.json(): для подписи нужно сырое тело.
app.post(
'/api/stripe-webhook',
express.raw({ type: 'application/json', limit: '1mb' }),
asyncHandler(async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.warn('Stripe signature verification failed:', err.message);
return res.status(400).send('Invalid signature');
}
if (event.type !== 'payment_intent.succeeded') {
return res.sendStatus(200);
}
const paymentIntent = event.data.object;
const orderId = parseOrderId(paymentIntent?.metadata?.order_id);
if (orderId === null) {
console.error('payment_intent.succeeded without valid metadata.order_id', {
eventId: event.id,
paymentIntentId: paymentIntent?.id,
});
return res.sendStatus(200); // ack: ретрай не исправит данные
}
const outcome = await withTransaction(async (client) => {
const inserted = await client.query(
`INSERT INTO processed_webhook_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[event.id, event.type]
);
if (inserted.rowCount === 0) return 'duplicate_event';
// TODO(#13): сверить сумму/валюту и допустимый исходный статус под вашу схему:
// AND status = 'pending' AND amount = $2 AND currency = $3
const updated = await client.query(
`UPDATE orders
SET status = 'paid'
WHERE id = $1 AND status IS DISTINCT FROM 'paid'
RETURNING id, amount, customer_email`,
[orderId]
);
if (updated.rowCount === 0) return 'already_paid_or_missing';
await client.query(
`INSERT INTO email_outbox (order_id, kind, payload)
VALUES ($1, 'receipt', $2)
ON CONFLICT (order_id, kind) DO NOTHING`,
[updated.rows[0].id, JSON.stringify(updated.rows[0])]
);
return 'paid';
});
if (outcome !== 'paid') {
console.info(`Webhook ${event.id}: ${outcome} (order ${orderId})`);
}
res.sendStatus(200); // быстрый ответ Stripe
if (outcome === 'paid') kickOutbox(); // письмо — асинхронно, вне запроса
})
);
app.use(express.json());
app.get(
'/api/orders',
// TODO(#14): добавить middleware аутентификации/авторизации
asyncHandler(async (req, res) => {
const limit = parseIntParam(req.query.limit, { def: 10, min: 1, max: 100 });
if (limit === null) {
return res.status(400).json({ error: 'limit must be an integer 1..100' });
}
// Keyset-режим: ?after_id=10&limit=10 (устойчив к удалениям, быстрый на глубине)
if (req.query.after_id !== undefined) {
const afterId = parseIntParam(req.query.after_id, { def: 0, min: 0, max: Number.MAX_SAFE_INTEGER });
if (afterId === null) {
return res.status(400).json({ error: 'after_id must be a non-negative integer' });
}
const { rows } = await pool.query(
`SELECT id, status, amount, customer_email
FROM orders
WHERE id > $1
ORDER BY id
LIMIT $2`,
[afterId, limit]
);
return res.json(rows);
}
const page = parseIntParam(req.query.page, { def: 1, min: 1, max: 100_000 });
if (page === null) {
return res.status(400).json({ error: 'page must be a positive integer' });
}
const offset = (page - 1) * limit;
const { rows } = await pool.query(
`SELECT id, status, amount, customer_email
FROM orders
ORDER BY id
LIMIT $1 OFFSET $2`,
[limit, offset]
);
res.json(rows);
})
);
app.get(
'/healthz',
asyncHandler(async (req, res) => {
await pool.query('SELECT 1');
res.json({ ok: true });
})
);
// Централизованный обработчик ошибок (последним)
app.use((err, req, res, next) => {
console.error(`[${req.method} ${req.originalUrl}]`, err);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Internal Server Error' });
});
const server = app.listen(PORT, () => console.log(`Server started on port ${PORT}`));
// ---------- Graceful shutdown (Render шлёт SIGTERM при деплое) ----------
function shutdown(signal) {
console.log(`${signal} received, shutting down`);
clearInterval(outboxTimer);
server.close(async () => {
try {
await pool.end();
transporter.close();
} finally {
process.exit(0);
}
});
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
module.exports = { app, pool }; // для тестов
Миграция
CREATE TABLE IF NOT EXISTS processed_webhook_events (
event_id text PRIMARY KEY,
event_type text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS email_outbox (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL,
attempts int NOT NULL DEFAULT 0,
locked_until timestamptz,
last_error text,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (order_id, kind)
);
CREATE INDEX IF NOT EXISTS email_outbox_pending_idx
ON email_outbox (id) WHERE sent_at IS NULL;
-- Опционально: чистка старых записей идемпотентности (Stripe ретраит до ~3 суток)
-- DELETE FROM processed_webhook_events WHERE processed_at < now() - interval '30 days';
Если orders.id имеет тип uuid, замените bigint в email_outbox.order_id и регулярку в parseOrderId.
Список изменений
- Вебхук регистрируется до
express.json()сexpress.raw(), подпись проверяется черезconstructEvent(#1). - Двухуровневая идемпотентность:
processed_webhook_eventsсON CONFLICTплюсstatus IS DISTINCT FROM 'paid'иUNIQUE (order_id, kind)в outbox (#2). withTransaction:BEGIN/COMMIT/ROLLBACK,release()вfinally, уничтожение сломанного клиента (#3, #4).- Все запросы идут через
await,RETURNINGзаменяет лишний SELECT (#4). asyncHandlerи централизованный error-middleware для Express 4 (#5).- Все SQL-запросы параметризованы (#6, #7).
offset = (page - 1) * limit; строгая валидацияpage,limit,after_id;limit ≤ 100(#8, #9).- Transactional Outbox: письмо ставится в очередь атомарно со сменой статуса, отправляется асинхронно с ретраями и lease; transporter создаётся один раз с пулом (#10).
- Отсутствующий или некорректный
order_idподтверждается 200 и логируется (#11). - Пул:
connectionTimeoutMillis,idleTimeoutMillis,statement_timeout,application_name,pool.on('error')(#12). - TODO-проверка суммы, валюты и допустимого исходного статуса (#13); явный список столбцов вместо
SELECT *(#14). PORTиз env, проверка обязательных env,/healthz, graceful shutdown (#15).- Опциональная keyset-пагинация
?after_id=со строгим>(#16).
Тесты (Jest + supertest, реальная тестовая БД)
const request = require('supertest');
const Stripe = require('stripe');
const { app, pool } = require('../server');
const stripe = Stripe('sk_test_dummy');
const secret = process.env.STRIPE_WEBHOOK_SECRET;
function signed(event) {
const payload = JSON.stringify(event);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
return { payload, header };
}
const piEvent = (id, orderId) => ({
id,
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_test', metadata: { order_id: String(orderId) } } },
});
beforeEach(async () => {
await pool.query('TRUNCATE processed_webhook_events, email_outbox');
await pool.query('TRUNCATE orders RESTART IDENTITY');
await pool.query(`
INSERT INTO orders (status, amount, customer_email)
SELECT 'pending', 1000, 'u' || g || '@example.com' FROM generate_series(1, 25) g`);
});
afterAll(() => pool.end());
test('rejects unsigned webhook', async () => {
await request(app).post('/api/stripe-webhook')
.set('Content-Type', 'application/json')
.send(JSON.stringify(piEvent('evt_1', 1)))
.expect(400);
});
test('same event delivered 5x concurrently → one update, one email', async () => {
const { payload, header } = signed(piEvent('evt_dup', 3));
const responses = await Promise.all(Array.from({ length: 5 }, () =>
request(app).post('/api/stripe-webhook')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', header)
.send(payload)));
responses.forEach((r) => expect(r.status).toBe(200));
const outbox = await pool.query('SELECT count(*)::int AS n FROM email_outbox WHERE order_id = 3');
expect(outbox.rows[0].n).toBe(1);
const { rows } = await pool.query('SELECT status FROM orders WHERE id = 3');
expect(rows[0].status).toBe('paid');
});
test('different events for the same order → still one email', async () => {
for (const id of ['evt_a', 'evt_b']) {
const { payload, header } = signed(piEvent(id, 4));
await request(app).post('/api/stripe-webhook')
.set('Content-Type', 'application/json').set('Stripe-Signature', header)
.send(payload).expect(200);
}
const { rows } = await pool.query('SELECT count(*)::int AS n FROM email_outbox WHERE order_id = 4');
expect(rows[0].n).toBe(1);
});
test('pagination: pages are disjoint and contiguous', async () => {
const p1 = (await request(app).get('/api/orders?page=1&limit=10')).body.map((o) => o.id);
const p2 = (await request(app).get('/api/orders?page=2&limit=10')).body.map((o) => o.id);
expect(p1).toEqual([1,2,3,4,5,6,7,8,9,10]);
expect(p2).toEqual([11,12,13,14,15,16,17,18,19,20]);
});
test.each(['abc', '-1', '0', '1.5', '101', '1;SELECT 1'])('invalid limit %s → 400', async (v) => {
await request(app).get(`/api/orders?limit=${encodeURIComponent(v)}`).expect(400);
});
test('no pool leak after many webhook calls', async () => {
for (let i = 0; i < 30; i++) {
const { payload, header } = signed(piEvent(`evt_leak_${i}`, (i % 25) + 1));
await request(app).post('/api/stripe-webhook')
.set('Content-Type', 'application/json').set('Stripe-Signature', header)
.send(payload).expect(200);
}
expect(pool.totalCount - pool.idleCount).toBe(0);
expect(pool.waitingCount).toBe(0);
});
Мониторинг на staging:
- Лог метрик пула каждые 30 с (
totalCount,idleCount,waitingCount), алерт наwaitingCount > 0дольше 30 с. - Алерты на
email_outbox: неотправленные дольше 10 минут иattempts >= 5. - В Stripe Dashboard → Webhooks: доля неуспешных доставок и время ответа эндпоинта.
- Алерт на
numbackendsизpg_stat_databaseвыше 80% отmax_connections.
Что нельзя установить по коду (нужны данные)
- Пагинация «10–19». Присланный код даёт 11–20 и 21–30, а не 1–10 и 10–19. Нужны commit SHA задеплоенной версии на Render, фактический SQL из лога Postgres (
log_min_duration_statement = 0временно) и то, какие параметры реально шлёт клиент (Network в DevTools). Моя основная гипотеза — курсорная пагинация с>=в задеплоенной версии или на клиенте. UnhandledPromiseRejectionWarningна Node 20. Это формат предупреждения из Node ≤14 или из режима--unhandled-rejections=warn. В Node 20 по умолчанию процесс падает, и вывод выглядит иначе. Проверьтеnode -vв Render Shell или в build-логах, полеengines.nodeвpackage.json, переменнуюNODE_VERSION/ файл.node-versionиNODE_OPTIONS. Если на staging реально старый Node, поведение при ошибках там отличается от локального.too many clients already. Один процесс с этим кодом ограничен 10 соединениями, поэтому ошибка сервера означает, что суммарно соединений больше, чемmax_connections. НужныSHOW max_connections;, вывод запроса поpg_stat_activityиз #12 в момент ошибки, число инстансов сервиса (и есть ли autoscaling), список других сервисов и воркеров на той же БД, а также используется ли cluster или PM2. Утечка в вебхуке (#3) реальна и исправлена, но сама по себе она даёт зависание запросов, а не эту серверную ошибку.- Схема
orders: типid, единицы и валютаamount, перечень статусов. От этого зависятparseOrderId, типemail_outbox.order_idи проверка из #13.
Похожие промпты
Проектировщик REST API
Проектирует REST API от ресурсной модели до спецификации: маршруты, статус-коды, пагинация, версионирование и формат ошибок — с примерами запросов и ответов.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».
SEO-аудит Next.js-приложения
Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.
Ревью backend-кода: архитектура и безопасность
Проводит ревью серверного кода: слои и границы модулей, работа с БД и транзакциями, обработка ошибок, инъекции и утечки данных — замечания ранжированы, правки «до/после».