fix(api): graceful shutdown, единый обработчик ошибок и корректные 404

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 15:53:18 +07:00
1 parent 0948455d15
commit 140ffdb7b1
10 files changed
+335 -9

No files matched your search

+49 -9
View File
@@ -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
}
+3
View File
@@ -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,
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify'
export interface ShutdownOptions {
timeoutMs: number
exit?: (code: number) => void
signals?: NodeJS.Signals[]
proc?: Pick<NodeJS.Process, 'on' | 'off'>
}
/**
* 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<void> | null = null
const shutdown = (reason: string, code = 0): Promise<void> => {
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)
},
}
}
+3
View File
@@ -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}`)