From 140ffdb7b184624b094a705d64ab68214e5a4884 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 15:53:18 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20graceful=20shutdown,=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B8=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 58 +++++++++++--- apps/api/src/config.ts | 3 + apps/api/src/lib/shutdown.ts | 67 ++++++++++++++++ apps/api/src/server.ts | 3 + apps/api/test/error-handler.test.ts | 77 ++++++++++++++++++ apps/api/test/shutdown.test.ts | 120 ++++++++++++++++++++++++++++ deploy/docker-compose.traefik.yml | 2 + deploy/docker/authportal/Dockerfile | 1 + docker-compose.yml | 2 + packages/db/src/index.ts | 11 +++ 10 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/lib/shutdown.ts create mode 100644 apps/api/test/error-handler.test.ts create mode 100644 apps/api/test/shutdown.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 94befc3..655801a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance } from 'fastify' +import type { FastifyError, FastifyInstance } from 'fastify' import Fastify from 'fastify' import cors from '@fastify/cors' import helmet from '@fastify/helmet' @@ -14,6 +14,7 @@ import { allPermissionKeys, } from '@authportal/shared' import { + closeDb, createDb, getUserApps, healthCheck, @@ -35,6 +36,9 @@ import { webauthnRoutes } from './routes/webauthn.js' import { startAuditRetentionJob } from './services/audit-retention.js' import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js' +/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */ +const API_PREFIXES = ['/api', '/oauth', '/.well-known'] + declare module 'fastify' { interface FastifyInstance { config: AppConfig @@ -97,11 +101,42 @@ export async function buildApp(opts: { const app = Fastify({ logger: { level: config.logLevel }, trustProxy: true, + // keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении + keepAliveTimeout: 65_000, + connectionTimeout: 0, + requestTimeout: 30_000, + bodyLimit: 1_048_576, }) app.decorate('config', config) app.decorate('db', db) app.decorate('sqlite', sqlite) + app.addHook('onClose', async () => { + closeDb(sqlite) + }) + + app.setErrorHandler((err: FastifyError | Error, req, reply) => { + const status = + err.name === 'ZodError' + ? 400 + : (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400 + ? (err as FastifyError).statusCode! + : 500 + if (status >= 500) { + req.log.error(err) + return reply.status(500).send({ + error: { code: 'INTERNAL', message: 'Internal server error' }, + }) + } + const code = + status === 429 + ? 'RATE_LIMITED' + : status === 400 + ? 'BAD_REQUEST' + : ((err as FastifyError).code ?? 'ERROR') + return reply.status(status).send({ error: { code, message: err.message } }) + }) + await app.register(sensible) await app.register(formbody) @@ -147,15 +182,20 @@ export async function buildApp(opts: { // Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём. preCompressed: true, }) - app.setNotFoundHandler((req, reply) => { - if (req.method === 'GET' && !req.url.startsWith('/api')) { - return reply.sendFile('index.html') - } - return reply - .status(404) - .send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) - }) } + const spa = Boolean(config.staticDir && existsSync(config.staticDir)) + app.setNotFoundHandler((req, reply) => { + if ( + spa && + req.method === 'GET' && + !API_PREFIXES.some((p) => req.url.startsWith(p)) + ) { + return reply.sendFile('index.html') + } + return reply + .status(404) + .send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + }) return app } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 9fd92aa..7808757 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -22,6 +22,8 @@ export const configSchema = z.object({ serverPort: z.coerce.number().int().positive().default(8080), staticDir: z.string().optional(), logLevel: z.string().default('info'), + /** Максимум на graceful shutdown, затем принудительный выход. */ + shutdownTimeoutMs: z.coerce.number().int().positive().default(8000), isProd: z.boolean(), auditIngestSecret: z.string().min(8).optional(), webauthnRpID: z.string().min(1), @@ -56,6 +58,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { serverPort: env.SERVER_PORT ?? 8080, staticDir: env.STATIC_DIR || undefined, logLevel: env.LOG_LEVEL ?? 'info', + shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000, isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd, diff --git a/apps/api/src/lib/shutdown.ts b/apps/api/src/lib/shutdown.ts new file mode 100644 index 0000000..2454d65 --- /dev/null +++ b/apps/api/src/lib/shutdown.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance } from 'fastify' + +export interface ShutdownOptions { + timeoutMs: number + exit?: (code: number) => void + signals?: NodeJS.Signals[] + proc?: Pick +} + +/** + * SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД), + * принудительный выход по таймауту, повторный сигнал — немедленный выход. + * Возвращает функцию отписки и ручной триггер (для тестов). + */ +export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) { + const exit = opts.exit ?? ((code: number) => process.exit(code)) + const proc = opts.proc ?? process + const signals = opts.signals ?? ['SIGTERM', 'SIGINT'] + let closing: Promise | null = null + + const shutdown = (reason: string, code = 0): Promise => { + if (closing) { + app.log.warn(`shutdown: repeated ${reason}, forcing exit`) + exit(1) + return closing + } + app.log.info(`shutdown: ${reason}`) + const timer = setTimeout(() => { + app.log.error('shutdown: timeout, forcing exit') + exit(1) + }, opts.timeoutMs) + timer.unref() + closing = app + .close() + .then(() => { + clearTimeout(timer) + exit(code) + }) + .catch((err: unknown) => { + clearTimeout(timer) + app.log.error(err, 'shutdown: close failed') + exit(1) + }) + return closing + } + + const handlers = signals.map((sig) => { + const h = () => void shutdown(sig) + proc.on(sig, h) + return [sig, h] as const + }) + const onFatal = (err: unknown) => { + app.log.error(err, 'fatal error') + void shutdown('fatal', 1) + } + proc.on('unhandledRejection', onFatal) + proc.on('uncaughtException', onFatal) + + return { + shutdown, + dispose() { + for (const [sig, h] of handlers) proc.off(sig, h) + proc.off('unhandledRejection', onFatal) + proc.off('uncaughtException', onFatal) + }, + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d6ef6ab..7941796 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' import { buildApp } from './app.js' import { loadConfig } from './config.js' +import { registerShutdown } from './lib/shutdown.js' for (const path of [ resolve(import.meta.dirname, '../../../.env'), @@ -31,6 +32,8 @@ for (const path of [ const config = loadConfig() const app = await buildApp({ config }) +registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs }) + try { await app.listen({ port: config.serverPort, host: '0.0.0.0' }) app.log.info(`auth-portal listening on ${config.serverPort}`) diff --git a/apps/api/test/error-handler.test.ts b/apps/api/test/error-handler.test.ts new file mode 100644 index 0000000..3a03615 --- /dev/null +++ b/apps/api/test/error-handler.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { buildTestApp } from './helpers.js' + +let app: Awaited> | undefined + +afterEach(async () => { + await app?.close() + app = undefined +}) + +describe('error handler', () => { + it('необработанная ошибка → 500 без деталей', async () => { + app = await buildTestApp() + app.get('/__boom', async () => { + throw new Error('secret internals') + }) + const res = await app.inject({ method: 'GET', url: '/__boom' }) + expect(res.statusCode).toBe(500) + expect(res.json()).toEqual({ + error: { code: 'INTERNAL', message: 'Internal server error' }, + }) + expect(res.body).not.toContain('secret internals') + }) + + it('ZodError → 400 BAD_REQUEST', async () => { + app = await buildTestApp() + app.get('/__zod', async () => z.object({ a: z.string() }).parse({})) + const res = await app.inject({ method: 'GET', url: '/__zod' }) + expect(res.statusCode).toBe(400) + expect(res.json().error.code).toBe('BAD_REQUEST') + }) + + it('ошибка с statusCode 4xx сохраняет статус', async () => { + app = await buildTestApp() + app.get('/__teapot', async () => { + throw Object.assign(new Error('nope'), { statusCode: 418 }) + }) + const res = await app.inject({ method: 'GET', url: '/__teapot' }) + expect(res.statusCode).toBe(418) + }) +}) + +describe('not found', () => { + it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])( + '%s → 404 JSON', + async (url) => { + app = await buildTestApp() + const res = await app.inject({ method: 'GET', url }) + expect(res.statusCode).toBe(404) + expect(res.json().error.code).toBe('NOT_FOUND') + }, + ) + + it('без STATIC_DIR неизвестный путь → 404 JSON', async () => { + app = await buildTestApp() + const res = await app.inject({ method: 'GET', url: '/some/spa/route' }) + expect(res.statusCode).toBe(404) + }) + + it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ap-static-')) + writeFileSync(join(dir, 'index.html'), 'spa') + app = await buildTestApp({ STATIC_DIR: dir }) + const spa = await app.inject({ method: 'GET', url: '/some/spa/route' }) + expect(spa.statusCode).toBe(200) + expect(spa.body).toContain('spa') + for (const url of ['/oauth/nope', '/.well-known/nope']) { + const res = await app.inject({ method: 'GET', url }) + expect(res.statusCode).toBe(404) + expect(res.json().error.code).toBe('NOT_FOUND') + } + }) +}) diff --git a/apps/api/test/shutdown.test.ts b/apps/api/test/shutdown.test.ts new file mode 100644 index 0000000..9bf5672 --- /dev/null +++ b/apps/api/test/shutdown.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDb, closeDb, migrateSchema } from '@authportal/db' +import { registerShutdown } from '../src/lib/shutdown.js' +import { buildTestApp } from './helpers.js' + +function fakeApp(close: () => Promise) { + return { + close, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + } as unknown as Parameters[0] +} + +describe('registerShutdown', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('закрывает приложение один раз и выходит с 0', async () => { + const close = vi.fn(async () => {}) + const exit = vi.fn() + const proc = new EventEmitter() + registerShutdown(fakeApp(close), { + timeoutMs: 1000, + exit, + proc: proc as never, + }) + proc.emit('SIGTERM') + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('повторный сигнал форсирует выход, close не вызывается дважды', async () => { + let release!: () => void + const close = vi.fn( + () => new Promise((r) => (release = r)), + ) + const exit = vi.fn() + const proc = new EventEmitter() + registerShutdown(fakeApp(close), { + timeoutMs: 1000, + exit, + proc: proc as never, + }) + proc.emit('SIGTERM') + proc.emit('SIGINT') + expect(exit).toHaveBeenCalledWith(1) + expect(close).toHaveBeenCalledTimes(1) + release() + }) + + it('по таймауту принудительно выходит с кодом 1', async () => { + vi.useFakeTimers() + const exit = vi.fn() + const proc = new EventEmitter() + registerShutdown( + fakeApp(() => new Promise(() => {})), + { timeoutMs: 500, exit, proc: proc as never }, + ) + proc.emit('SIGTERM') + await vi.advanceTimersByTimeAsync(501) + expect(exit).toHaveBeenCalledWith(1) + }) + + it('ошибка close → выход с кодом 1', async () => { + const exit = vi.fn() + const proc = new EventEmitter() + registerShutdown( + fakeApp(async () => { + throw new Error('boom') + }), + { timeoutMs: 1000, exit, proc: proc as never }, + ) + proc.emit('SIGTERM') + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)) + }) + + it('unhandledRejection запускает shutdown с кодом 1', async () => { + const exit = vi.fn() + const proc = new EventEmitter() + registerShutdown(fakeApp(async () => {}), { + timeoutMs: 1000, + exit, + proc: proc as never, + }) + proc.emit('unhandledRejection', new Error('x')) + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)) + }) + + it('dispose снимает обработчики', () => { + const proc = new EventEmitter() + const h = registerShutdown(fakeApp(async () => {}), { + timeoutMs: 1000, + exit: vi.fn(), + proc: proc as never, + }) + h.dispose() + expect(proc.listenerCount('SIGTERM')).toBe(0) + expect(proc.listenerCount('unhandledRejection')).toBe(0) + }) +}) + +describe('closeDb', () => { + it('идемпотентно закрывает БД', () => { + const { sqlite } = createDb('sqlite::memory:') + migrateSchema(sqlite) + closeDb(sqlite) + expect(sqlite.open).toBe(false) + expect(() => closeDb(sqlite)).not.toThrow() + expect(() => sqlite.prepare('SELECT 1')).toThrow() + }) + + it('app.close() закрывает БД приложения', async () => { + const app = await buildTestApp() + await app.ready() + expect(app.sqlite.open).toBe(true) + await app.close() + expect(app.sqlite.open).toBe(false) + }) +}) diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml index ab1b62a..f750721 100644 --- a/deploy/docker-compose.traefik.yml +++ b/deploy/docker-compose.traefik.yml @@ -54,6 +54,8 @@ services: pull_policy: always container_name: auth-portal restart: unless-stopped + init: true + stop_grace_period: 15s depends_on: - traefik # No host ports — only Traefik publishes 80/443. diff --git a/deploy/docker/authportal/Dockerfile b/deploy/docker/authportal/Dockerfile index 97d7bda..210d889 100644 --- a/deploy/docker/authportal/Dockerfile +++ b/deploy/docker/authportal/Dockerfile @@ -52,6 +52,7 @@ ENV NODE_ENV=production \ COPY --from=build --chown=node:node /out ./ USER node EXPOSE 8080 +STOPSIGNAL SIGTERM VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] diff --git a/docker-compose.yml b/docker-compose.yml index 802be10..7698e0b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: volumes: - ./data:/data restart: unless-stopped + init: true + stop_grace_period: 15s healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5732549..3a5b9d4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -215,6 +215,17 @@ export function migrateSchema(sqlite: Sqlite): void { `) } +/** Идемпотентное закрытие: checkpoint WAL, обновление статистики планировщика, close. */ +export function closeDb(sqlite: Sqlite): void { + if (!sqlite.open) return + try { + if (!sqlite.memory) sqlite.pragma('wal_checkpoint(TRUNCATE)') + sqlite.pragma('optimize') + } finally { + sqlite.close() + } +} + export function healthCheck(sqlite: Sqlite): void { sqlite.prepare('SELECT 1').get() }