Programowanie 24 април 2026 г. 28 min

HTTP кодове — пълно ръководство за програмисти: всички статуси, приложения и безопасни капани

Повечето документи за HTTP кодовете са списъци от 63 позиции с едноредово определение. Много малко произтича от това. Този документ отива на противоположния път — седем реални сценария, които пишеш в бекенда, всеки с полния работещ код Fastify + Prisma, показан в две версии: със сигурностни бъгове и без. HTTP кодовете се появяват там, където всички се появяват — в контекст. В края — референтна таблица на всички 63 кода с присвояване на RFC — когато трябва да търсиш по номер.

1. REST API — CRUD за публикации

Започваме с най-честия случай. Пишем крайна точка за управление на публикации на блог. В нея естествено ще се появят осем HTTP кода: 200, 201, 204, 304, 400, 404, 409, 422. Всеки на своето място, не защото „трябвало е да го използвам", а защото решава конкретен проблем.

Пълна крайна точка — Fastify + Prisma

// apps/api/src/routes/posts.ts
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import crypto from 'node:crypto'
import { prisma } from '../lib/prisma'

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  publishAt: z.string().datetime().optional(),
  category: z.enum(['tech', 'business', 'security']),
})

const UpdatePostSchema = CreatePostSchema.partial()

function etag(updatedAt: Date) {
  return `"${crypto.createHash('sha1').update(updatedAt.toISOString()).digest('hex').slice(0, 16)}"`
}

export async function postsRoutes(fastify: FastifyInstance) {

  // СПИСЪК — GET /posts
  fastify.get('/posts', async (req, reply) => {
    const posts = await prisma.post.findMany({
      where: { deletedAt: null },
      orderBy: { createdAt: 'desc' },
      take: 50,
    })
    return reply.send(posts)   // 200 OK
  })

  // ПРОЧИТАЙ — GET /posts/:id (с ETag и 304 валидирани)
  fastify.get<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
    const post = await prisma.post.findFirst({
      where: { id: req.params.id, deletedAt: null },
    })
    if (!post) {
      return reply.code(404).send({ error: 'post_not_found' })
    }
    const currentEtag = etag(post.updatedAt)
    if (req.headers['if-none-match'] === currentEtag) {
      return reply.code(304).send()                     // без тяло
    }
    return reply.header('ETag', currentEtag).send(post) // 200 OK с ETag
  })

  // СЪЗДАЙ — POST /posts
  fastify.post('/posts', async (req, reply) => {
    const parsed = CreatePostSchema.safeParse(req.body)
    if (!parsed.success) {
      return reply.code(400).send({                     // 400 — лоша структура
        error: 'invalid_body',
        issues: parsed.error.flatten(),
      })
    }
    const data = parsed.data

    // Валидирани на бизнеса — 422, защото форматът беше OK
    if (data.publishAt && new Date(data.publishAt) < new Date()) {
      return reply.code(422).send({
        error: 'publish_at_in_past',
        message: 'publishAt трябва да е в бъдещето',
      })
    }

    try {
      const post = await prisma.post.create({
        data: {
          title: data.title,
          content: data.content,
          category: data.category,
          publishAt: data.publishAt ? new Date(data.publishAt) : null,
          authorId: req.user.id,
        },
      })
      return reply
        .code(201)                                      // 201 Created
        .header('Location', `/posts/${post.id}`)
        .header('ETag', etag(post.updatedAt))
        .send(post)
    } catch (err: any) {
      if (err.code === 'P2002') {                       // Prisma unique constraint
        return reply.code(409).send({                   // 409 Conflict
          error: 'slug_already_exists',
        })
      }
      throw err
    }
  })

  // ОБНОВИ — PATCH /posts/:id (оптимистични паралелизъм чрез If-Match)
  fastify.patch<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
    const parsed = UpdatePostSchema.safeParse(req.body)
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.flatten() })
    }

    const existing = await prisma.post.findFirst({
      where: { id: req.params.id, deletedAt: null },
    })
    if (!existing) {
      return reply.code(404).send({ error: 'post_not_found' })
    }
    if (existing.authorId !== req.user.id && !req.user.isAdmin) {
      return reply.code(403).send({ error: 'forbidden' })
    }

    // Оптимистични блокиране — клиентът изпрати If-Match с ETag
    const ifMatch = req.headers['if-match']
    if (ifMatch && ifMatch !== etag(existing.updatedAt)) {
      return reply.code(412).send({                     // 412 Precondition Failed
        error: 'stale_version',
        message: 'Някой промени публикацията междуинфекцията — получете актуалната версия',
      })
    }

    const updated = await prisma.post.update({
      where: { id: existing.id },
      data: parsed.data,
    })
    return reply.header('ETag', etag(updated.updatedAt)).send(updated)   // 200 OK
  })

  // ИЗТРИЙ — DELETE /posts/:id
  fastify.delete<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
    const post = await prisma.post.findFirst({
      where: { id: req.params.id, deletedAt: null },
    })
    if (!post) {
      return reply.code(404).send({ error: 'post_not_found' })
    }
    if (post.authorId !== req.user.id && !req.user.isAdmin) {
      return reply.code(403).send({ error: 'forbidden' })
    }
    await prisma.post.update({
      where: { id: post.id },
      data: { deletedAt: new Date() },
    })
    return reply.code(204).send()                       // 204 No Content
  })
}

Защо тези кодове, а не други — решение след решение

Защо 201 + Location вместо 200

200 OK казва „операцията е успешна". 201 Created казва „успешна е и се е появил нов обект". Разликата е практична: клиент, който получи 201 с Location: /posts/abc, знае точно, че може да направи GET /posts/abc и да получи новосъздадения ресурс. Клиент, който получи 200, трябва да извлече ID от тялото — което изисква договор. Договорът може да се промени и да счупи клиентите; Location е стандарт.

Защо 204 за DELETE вместо 200

Мекото изтриване е успешно, нямаме нищо разумно да върнем — 204 No Content. RFC 9110 забранява тяло при 204, така че fetch() в браузъра автоматично третира този отговор като „без тяло" и не се опитва да парсира JSON. Връщането на 200 с {"deleted":true} принуждава клиента да обработи такъв формат; 204 е стандарт.

Защо разграничавам 400 и 422

Zod връща два типа грешки. „Липсва поле title" — синтаксис, форматът на заявката е лош, 400 Bad Request. „publishAt в миналото" — форматът е OK (това е валидна дата ISO), но логиката на домейна отказва. Това е 422 Unprocessable Content. GitHub, Stripe, Rails последователно правят така — и има смисъл, защото клиентът третира 400 като бъг (съставяме лошо заявката), а 422 като отказ (потребителят се опитва да направи нещо незаконно от бизнес гледна точка).

Защо 409 за unique constraint

Prisma хвърля P2002 когато двама потребители едновременно вметнат публикация със същия slug. Кодът 400 би бил подвеждащ — заявката беше правилна. 422 би бил странен — това не е бизнес правило, това е конфликт на състоянието. 409 Conflict е точно това: „не мога да направя това, защото коидира с текущото състояние".

Защо 412 при оптимистични блокиране

If-Match е предварително условие. Когато не е изпълнено, спецификацията (RFC 9110 §13.1.1) казва ясно: 412 Precondition Failed. Не 409 — клиентът не е в конфликт, той е попаднал в него, защото има стара версия.

Защо 304 вместо да пресервира целия отговор

Клиент със ETag "abc123" изпраща If-None-Match: "abc123". Ако публикацията не се е променила — връщаме 304, пусто тяло. Това пестели честотна лента (публикацията може да има 50 KB съдържание), пестели сериализирането на JSON, пестели gzip. Cloudflare/Varnish разбират 304 и не се опитват да го кешират като отговор. В типичен CMS — 80%+ заявки към публикувани публикации могат да приключат с 304.

Тест проверяващ решенията

// apps/api/tests/posts.test.ts
import { test, expect } from 'vitest'
import { buildApp } from '../src/app'

test('POST /posts връща 201 + Location', async () => {
  const app = buildApp()
  const res = await app.inject({
    method: 'POST', url: '/posts',
    headers: { authorization: 'Bearer test' },
    payload: { title: 'X', content: 'Y', category: 'tech' },
  })
  expect(res.statusCode).toBe(201)
  expect(res.headers.location).toMatch(/^\/posts\//)
  expect(res.headers.etag).toBeTruthy()
})

test('POST /posts с publishAt в миналото → 422 (не 400)', async () => {
  const res = await app.inject({
    method: 'POST', url: '/posts',
    headers: { authorization: 'Bearer test' },
    payload: {
      title: 'X', content: 'Y', category: 'tech',
      publishAt: '2020-01-01T00:00:00Z',  // миналото
    },
  })
  expect(res.statusCode).toBe(422)   // семантика, не синтаксис
  expect(res.json().error).toBe('publish_at_in_past')
})

test('DELETE връща 204 без тяло', async () => {
  const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
  expect(res.statusCode).toBe(204)
  expect(res.body).toBe('')         // празен низ, не "{}"
})

test('GET с If-None-Match връща 304 без тяло', async () => {
  const first = await app.inject({ method: 'GET', url: '/posts/abc' })
  const etag = first.headers.etag

  const second = await app.inject({
    method: 'GET', url: '/posts/abc',
    headers: { 'if-none-match': etag },
  })
  expect(second.statusCode).toBe(304)
  expect(second.body).toBe('')
})

test('PATCH със стара If-Match → 412', async () => {
  const res = await app.inject({
    method: 'PATCH', url: '/posts/abc',
    headers: { 'if-match': '"stara-wersja"' },
    payload: { title: 'Nowy' },
  })
  expect(res.statusCode).toBe(412)
})

2. Вход в системата и сесии — 4 реални бъга, 4 поправки

Крайната точка на входа е едно място, където четири различни сигурностни бъга се разкриват чрез неприметни решения по код HTTP / форма на отговор / логиране. По-долу пълния път: първо версия с бъгове (точно такава, както я пишем при първата итерация), след това всеки бъг извлечен на стол и поправен, след това окончателната версия с тест.

Версия с бъгове (НЕ ИЗПОЛЗВАЙ)

// apps/api/src/routes/auth/login.ts — версия с 4 бъга
export async function loginRoute(fastify: FastifyInstance) {
  fastify.post('/login', async (req, reply) => {
    const { email, password } = req.body as { email: string; password: string }

    req.log.info({ email, password }, 'login attempt')   // БЪГ #4

    const user = await prisma.user.findUnique({ where: { email } })
    if (!user) {                                          // БЪГ #1
      return reply.code(404).send({ error: 'user_not_found' })
    }

    const ok = await bcrypt.compare(password, user.passwordHash)
    if (!ok) {                                            // БЪГ #2
      return reply.code(401).send({ error: 'wrong_password' })
    }

    const attempts = await redis?.incr(`login:${email}`).catch(() => null)
    if (attempts === null) {                              // БЪГ #3
      // Redis отпадна — позволяваме напълно, сигурност < наличност
    } else if (attempts > 5) {
      return reply.code(429).send({ error: 'too_many' })
    }

    const token = fastify.jwt.sign({ userId: user.id })
    return reply.send({ token })
  })
}

Бъг #1 — изброителност на потребителят чрез различни кодове

Крайната точка връща 404 user_not_found когато имейлът не е там, 401 wrong_password когато е там, но паролата е лоша. Нападателят със списък от 500 хиляди имейли (течение от друг сайт) ще филтрира в няколко часа всички активни акаунти на вашата фирма. Кодовете HTTP попадат в прокси/CDN/access логове — не е нужно даже да четете тялото.

Поправка:

// DUMMY_HASH — предварително изчислен bcrypt хеш на низа "dummy" (cost 10)
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'

const user = await prisma.user.findUnique({ where: { email } })
const hashToCompare = user?.passwordHash ?? DUMMY_HASH
const ok = await bcrypt.compare(password, hashToCompare)

if (!user || !ok) {
  return reply.code(401).send({ error: 'invalid_credentials' })
}

Сега нападателят получава идентичния 401 invalid_credentials независимо дали акаунтът съществува. Няма начин да се различи.

Бъг #2 — timing атака

Поправката от #1 само привидно решава проблема. Чейтайки го внимателно — когато user не съществува, hashToCompare = DUMMY_HASH, bcrypt.compare работи ~80 ms. Когато user съществува, bcrypt сравнява с неговия истински хеш — също ~80 ms (ако цената е същата). OK, в този конкретен фрагмент редактирането е балансирано.

Но внимание: в оригиналната версия с бъгове първият клон if (!user) return ... се изпълнява ~2 ms (само заявка към базата), втория ~82 ms (заявка + bcrypt). Нападателят измерва средния час от 100 опита:

// attacker.ts — показва разликата
async function measureLogin(email: string) {
  const samples = 100
  const start = Date.now()
  for (let i = 0; i < samples; i++) {
    await fetch('/login', { method: 'POST', body: JSON.stringify({ email, password: 'wrong' }) })
  }
  return (Date.now() - start) / samples
}

console.log('exists:', await measureLogin('real@company.pl'))      // ~82ms
console.log('notexists:', await measureLogin('random@xxxxxx.pl'))  // ~2ms

Разликата от 80 ms е вечност — нападателят брои акаунти без да гледа HTTP кода. Поправката изисква dummy bcrypt.compare за несъществуващи потребители (вижте кода по-горе — той е вече правилен). Допълнително тест за регресия:

test('вход не разкрива съществуването на потребител чрез редактирането', async () => {
  const warmup = 10, samples = 200

  // загрев
  for (let i = 0; i < warmup; i++) await login('whatever@nowhere.xx', 'wrong')

  const tExists: number[] = []
  const tNotExists: number[] = []

  for (let i = 0; i < samples; i++) {
    const t1 = performance.now()
    await login('real@company.pl', 'wrong')
    tExists.push(performance.now() - t1)

    const t2 = performance.now()
    await login('random' + i + '@xxxxxx.xx', 'wrong')
    tNotExists.push(performance.now() - t2)
  }
  const median = (arr: number[]) => arr.sort()[arr.length / 2 | 0]
  const diff = Math.abs(median(tExists) - median(tNotExists))
  expect(diff).toBeLessThan(5)   // по-малко от 5ms разлика
})

Бъг #3 — отворено на честотата лимит

redis?.incr(...).catch(() => null) — когато Redis отпадне, attempts === null, условието attempts > 5 е false, лимитът е отключен. Реален инцидент (от одит на PageForYou.pl през април 2026): OOM на Redis + нападателят в същия момент + 200 хиляди опита за пароли преди някой да забелязва.

Поправка — отворено затворено:

const key = `login:${email}:${req.ip}`
let attempts: number
try {
  attempts = await redis.incr(key)
  if (attempts === 1) await redis.expire(key, 60)   // плъзгащ прозорец 1 мин
} catch {
  // Redis отпадна — НЕ позволяваме напълно
  return reply
    .code(429)
    .header('Retry-After', '30')
    .send({ error: 'rate_limiter_unavailable' })
}
if (attempts > 5) {
  return reply
    .code(429)
    .header('Retry-After', '60')
    .send({ error: 'too_many_attempts' })
}

Трейд-оф, осъзнат: при падане на Redis никой не се влиза в системата през ~30 сек (време на възвращане на Redis + истичане на срока). Приемаме — сигурност > наличност. Алтернативата е локален fallback (in-memory map), но тогава разпределената система има некоординиран лимит — всеки под брои отделно, реално N× 5 опита вместо 5.

Бъг #4 — пароля в логовете

req.log.info({ email, password }, ...) → Loki/ELK/Splunk получават паролата в plaintext. Всеки с достъп до мониторинга (SRE, DevOps, изпълнител) ще я види. Класически течение.

Поправка — Fastify logger redact:

import Fastify from 'fastify'

const fastify = Fastify({
  logger: {
    level: 'info',
    redact: {
      paths: [
        'req.body.password',
        'req.body.currentPassword',
        'req.body.newPassword',
        'req.headers.authorization',
        'req.headers.cookie',
      ],
      censor: '[REDACTED]',
    },
  },
})

Редактирането работи на ниво сериализер Pino — паролите никога не попадат в транспорта, дори по случайност в друго място на кода.

Окончателна версия на крайната точка

// apps/api/src/routes/auth/login.ts — след всички 4 поправки
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { prisma } from '../../lib/prisma'
import { redis } from '../../lib/redis'

const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(1) })

export async function loginRoute(fastify: FastifyInstance) {
  fastify.post('/login', async (req, reply) => {
    const parsed = LoginSchema.safeParse(req.body)
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_body' })
    }
    const { email, password } = parsed.data

    // 1. Честотна лимит (отворено затворено)
    const rateKey = `login:${email.toLowerCase()}:${req.ip}`
    try {
      const attempts = await redis.incr(rateKey)
      if (attempts === 1) await redis.expire(rateKey, 60)
      if (attempts > 5) {
        return reply
          .code(429)
          .header('Retry-After', '60')
          .header('WWW-Authenticate', 'Bearer realm="api"')
          .send({ error: 'too_many_attempts' })
      }
    } catch {
      return reply
        .code(429)
        .header('Retry-After', '30')
        .send({ error: 'rate_limiter_unavailable' })
    }

    // 2. Търсене на потребител с постоянно време + сравнение
    const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } })
    const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH)

    if (!user || !ok) {
      return reply
        .code(401)
        .header('WWW-Authenticate', 'Bearer realm="api"')
        .send({ error: 'invalid_credentials' })
    }

    if (user.disabledAt) {
      return reply.code(403).send({ error: 'account_disabled' })
    }

    // Успех — нулиран честотния лимит + издаване на жетон
    await redis.del(rateKey).catch(() => {})
    const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
    return reply.send({ token, expiresIn: 900 })
  })
}

Защо 401 вместо 403 за неудачен вход

Класическа грешка: тъй като потребителят е въвел „лоша пароля", може би 403 Forbidden? Не. 401 означава „не знам кой си, удостовер се". 403 означава „знам кой си, но нямаш разрешение за този ресурс". При входа удостоверяването е неудачно — 401. Използвам 403 само за отключен акаунт, където идентичността е била OK, но политиката отказва да продължи.

3. Пренасочвания — 5 сценария без самораняване

Пет различни пренасочвания, всеки с различна причина. Избора на грешния код променя POST на GET, разбива SEO, или отвори отворено пренасочване.

Сценарий 1: HTTP → HTTPS (глобален)

Код: 301 + HSTS. Трайно, за всички методи (GET доминира; за POST е същото е правилно, браузърът ще направи каквото трябва).

# /etc/nginx/sites-enabled/example.pl
server {
  listen 80;
  server_name example.pl www.example.pl;
  return 301 https://$host$request_uri;
}
server {
  listen 443 ssl http2;
  server_name example.pl www.example.pl;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
  # ...
}

Капан: първата заявка HTTP може да съдържа бисквитки за сесия в plaintext. HSTS решава това само от втората посещение — след първия успешен HTTPS отговор браузърът запомня и не опитва вече HTTP. Първата посещение трябва да е или чрез explicit https://, или преекспортирана листа (hstspreload.org).

Сценарий 2: Стар URL → нов след преименуване на брандинга

Код: 301. Google ще отиндексира стария URL и ще препишат rang на страницата.

fastify.get('/blog-old/:slug', (req, reply) => {
  return reply.redirect(301, `/blog/${req.params.slug}`)
})

Капан: 301 е агресивно кеширан от браузъра (понякога завинаги). Ако направиш грешка на целевия URL, потребителите ще ландват там дори след поправката, докато не изчистят кеша. Ако нямаш 100% сигурност — използвай 302 в началото, промени на 301 когато си сигурен.

Сценарий 3: Post-Redirect-Get след формуляр

Код: 303 See Other. Налага GET вместо POST — F5 на страница на потвърждение не причинява дублиране на поръчка.

fastify.post('/checkout', async (req, reply) => {
  const order = await createOrder(req.body)
  return reply.code(303).header('Location', `/orders/${order.id}`).send()
})

Разлика с 302: 302 исторически е трябвало да означава „запазване на метода", но браузърите винаги са менели POST на GET. RFC 9110 документира това несъответствие. 303 е експлицитно за това — без двусмисленост.

Сценарий 4: Трайна промяна на крайна точка API с POST

Код: 308 Permanent Redirect. За разлика от 301, гарантира запазване на метода и тялото.

// Стара крайна точка /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
  return reply.redirect(308, '/api/v2/orders')
})

Клиент, който е изпратил POST с тяло 2 KB, ще получи 308 Location: /api/v2/orders и ще изпълни POST със същото тяло. 301 е НЕ БЕЗОПАСЕН — някои HTTP библиотеки (стари curl, стари axios) ще променят POST на GET.

Сценарий 5: Отворено пренасочване — класически фишинг атака

Най-честата уязвимост на приложението, която видя на одит. Крайна точка за проследяване на щракване:

// УЯЗВИМО — НЕ ИЗПОЛЗВАЙ
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
  return reply.redirect(302, req.query.url)
})

Атака: SMS „Твоята сметка bank.pl ще бъде блокирана, проверете: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Жертвата вижда доверена домейна, щракна, получава 302 Location: https://bank-login-verify.evil/auth, ландва на фишинга. Въвеждане на пароля = превземат сметка.

Поправка — whitelist:

const ALLOWED_HOSTS = new Set([
  'bank.pl', 'mobile.bank.pl', 'blog.bank.pl',
])

fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
  let target: URL
  try {
    target = new URL(req.query.url)
  } catch {
    return reply.code(400).send({ error: 'invalid_url' })
  }

  // Само HTTPS, само известни хостове
  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
    return reply.code(400).send({ error: 'domain_not_allowed' })
  }

  return reply.redirect(302, target.toString())
})

Избора на 302 (не 301) е целен — не искаме браузърът да кеширани „щракни тук → ландва там", това трябва да е свежо решение при всяко щракване.

4. Качване и range заявки — включително Apache Killer

Качване на файл е място, където се появяват 413 (файл твърде голям), 415 (грешен тип), 416 (невалидна дължина) и 206 (частично съдържание — видео със преместване). Плюс Apache Killer — 15-годишна атака, която все още работи на неправилно конфигурирани сървъри.

Крайна точка за качване с валидирани

import multipart from '@fastify/multipart'
import { pipeline } from 'node:stream/promises'
import fs from 'node:fs'
import crypto from 'node:crypto'

const ALLOWED_MIME = new Set([
  'image/jpeg', 'image/png', 'image/webp',
  'application/pdf',
])
const MAX_SIZE = 10 * 1024 * 1024   // 10 MB

export async function uploadRoute(fastify: FastifyInstance) {
  await fastify.register(multipart, {
    limits: {
      fileSize: MAX_SIZE,
      files: 1,
      fields: 5,
      headerPairs: 200,
    },
  })

  fastify.post('/upload', async (req, reply) => {
    const data = await req.file()
    if (!data) return reply.code(400).send({ error: 'no_file' })

    if (!ALLOWED_MIME.has(data.mimetype)) {
      return reply.code(415).send({            // 415 Unsupported Media Type
        error: 'mime_not_allowed',
        allowed: [...ALLOWED_MIME],
      })
    }

    const id = crypto.randomBytes(16).toString('hex')
    const ext = data.mimetype.split('/')[1]
    const path = `/srv/uploads/${id}.${ext}`

    try {
      await pipeline(data.file, fs.createWriteStream(path))
    } catch (err: any) {
      if (err.code === 'FST_REQ_FILE_TOO_LARGE') {
        return reply.code(413).send({          // 413 Content Too Large
          error: 'file_too_large',
          maxBytes: MAX_SIZE,
        })
      }
      throw err
    }

    // Sanity проверка — multipart лимит може да бъде заобиколен, проверете реалния размер
    const stat = await fs.promises.stat(path)
    if (stat.size > MAX_SIZE) {
      await fs.promises.unlink(path)
      return reply.code(413).send({ error: 'file_too_large' })
    }

    // Magic байтове проверка — mimetype от заглавка може да лъже
    const realMime = await detectMimeFromBytes(path)   // напр. file-type библ.
    if (!ALLOWED_MIME.has(realMime)) {
      await fs.promises.unlink(path)
      return reply.code(415).send({ error: 'mime_mismatch' })
    }

    return reply.code(201)
      .header('Location', `/files/${id}`)
      .send({ id, size: stat.size, mime: realMime })
  })
}

Защо magic байтове проверка — не вярвайте на клиента

data.mimetype е Content-Type от multipart заглавката — клиентът може да въведе всеки низ там. Нападателят ще прати .exe с Content-Type: image/png, а след това в друго място на приложението този файл ще бъде даден на img src — което не е опасно — но на iframe или Content-Type в отговор, и браузърът ще изпълни бинарката. Проверка чрез file-type/libmagic чете първите байтове и разпознава типа наистина.

Range заявки — сервиране на видео със преместване

Видео плейър изпраща Range: bytes=1048576-2097151 — иска средната част. Сървърът отговаря 206 Partial Content.

fastify.get<{ Params: { id: string } }>('/videos/:id', async (req, reply) => {
  const video = await prisma.video.findUnique({ where: { id: req.params.id } })
  if (!video) return reply.code(404).send({ error: 'not_found' })

  const stat = await fs.promises.stat(video.path)
  const range = req.headers.range

  if (!range) {
    return reply
      .header('Content-Length', stat.size.toString())
      .header('Accept-Ranges', 'bytes')
      .type('video/mp4')
      .send(fs.createReadStream(video.path))   // 200 OK, целост
  }

  const match = /^bytes=(\d+)-(\d*)$/.exec(range)
  if (!match) {
    return reply.code(416).send({ error: 'invalid_range' })
  }
  const start = parseInt(match[1], 10)
  const end = match[2] ? parseInt(match[2], 10) : stat.size - 1

  if (start >= stat.size || end >= stat.size || start > end) {
    return reply
      .code(416)
      .header('Content-Range', `bytes */${stat.size}`)
      .send()
  }

  const chunkSize = end - start + 1
  return reply
    .code(206)                                  // 206 Partial Content
    .header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
    .header('Accept-Ranges', 'bytes')
    .header('Content-Length', chunkSize.toString())
    .type('video/mp4')
    .send(fs.createReadStream(video.path, { start, end }))
})

Apache Killer — CVE-2011-3192

Нападателят изпраща заглавка с хиляди непокриващи се диапазони:

Range: bytes=0-,0-1,0-2,0-3,0-4,0-5,0-6,0-7,...×1300

Сървърът се опитва да подготви и обедини всички тези фрагменти — OOM, падане. Поправка в Apache/Nginx е била приемена преди дълго време, но ако пишеш собствена обработка на Range (по-горе) — трябва да ограничиш брой и размер на диапазонроссии. Мойто обработчик приема само един диапазон, което е безопасно. Пълният RFC 7233 поддържа multipart/byteranges с няколко диапазона — реализирай само ако наистина се нуждаеш, с лимит максимално 10 диапазона.

5. Асинхронни работи — 202 Accepted + polling

Export ZIP има ~62 секунди при голяма сметка (PageForYou RODO експорт). Синхронно — браузърът връща timeout, Cloudflare 524. Правилно — 202 Accepted + крайна точка на статус + polling на клиентската страна.

Крайна точка създаваща работа

import { Queue } from 'bullmq'
const exportQueue = new Queue('exports', { connection: redisConfig })

fastify.post('/exports', async (req, reply) => {
  const job = await exportQueue.add('generate-rodo-zip', {
    userId: req.user.id,
  }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
  })

  return reply
    .code(202)                                        // 202 Accepted
    .header('Location', `/exports/${job.id}`)
    .send({
      jobId: job.id,
      status: 'queued',
      statusUrl: `/exports/${job.id}`,
    })
})

Крайна точка на статус — какво да върнеш, зависимо от етап

fastify.get<{ Params: { id: string } }>('/exports/:id', async (req, reply) => {
  const job = await exportQueue.getJob(req.params.id)
  if (!job) return reply.code(404).send({ error: 'job_not_found' })

  // Оторизирани — само собственик на работа
  if (job.data.userId !== req.user.id) {
    return reply.code(404).send({ error: 'job_not_found' })   // 404 целено, не 403
  }

  const state = await job.getState()

  if (state === 'completed') {
    // Готова — пренасочване към download URL
    return reply.code(303).header('Location', job.returnvalue.downloadUrl).send()
  }

  if (state === 'failed') {
    return reply.code(500).send({
      error: 'job_failed',
      attempts: job.attemptsMade,
      failedReason: job.failedReason,
    })
  }

  // Все още се изпълнява
  return reply
    .code(200)
    .header('Retry-After', '5')    // намек за клиента — poll след 5s
    .send({
      jobId: job.id,
      status: state,                 // waiting, active, delayed
      progress: job.progress,
    })
})

Защо 404 вместо 403 при чужда работа

Нападателят познаващ формата на ID работа може да брои /exports/0, /exports/1, ... и по статусите (403 vs 404) картира активност на други потребители. Връщането винаги 404 когато работата не е Твоя — нападателят не разграничава „не съществува" от „не Твоя".

Защо 303 след завършване, не 200

Клиент питащ за статус иска URL за сваляне когато готова. 303 See Other с Location: signed-url казва „отидете там GET-ем". Клиентът може да следва пренасочването автоматично, потребителят получава файл. 200 с { downloadUrl: "..." } изисква от клиента отделен GET и отделна логика — 303 е по-чисто.

6. Валидирани кеш — ETag и 304

Пълен пример на кеш валидирани в блог API, както този, който четеш. Цел: намали честотна лента 10×, понижи времето на рендиране SSR.

Крайна точка с ETag и валидирани

import crypto from 'node:crypto'

function computeEtag(post: { id: string; updatedAt: Date; version: number }) {
  const raw = `${post.id}:${post.updatedAt.toISOString()}:${post.version}`
  return `"${crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16)}"`
}

fastify.get<{ Params: { slug: string } }>('/posts/slug/:slug', async (req, reply) => {
  const post = await prisma.post.findFirst({
    where: { slug: req.params.slug, status: 'published' },
    select: { id: true, updatedAt: true, version: true, /* и остатък от полята */
              title: true, content: true, excerpt: true, coverImage: true },
  })

  if (!post) return reply.code(404).send({ error: 'not_found' })

  const currentEtag = computeEtag(post)
  const ifNoneMatch = req.headers['if-none-match']

  // Валидирани кеш — клиентът има актуална версия?
  if (ifNoneMatch === currentEtag) {
    return reply
      .code(304)                                   // 304 Not Modified
      .header('ETag', currentEtag)
      .header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
      .send()                                      // пусто тяло
  }

  return reply
    .code(200)
    .header('ETag', currentEtag)
    .header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
    .header('Vary', 'Accept-Language')
    .send(post)
})

Как това използва Next.js / Cloudflare

Next.js ISR с revalidate: 60 ще вика fetch към твоя API всеки 60 сек. С ETag:

  • Първи fetch → 200 OK + ETag + тяло (50 KB)
  • Следваща переваличане на fetch (всеки 60 сек) → If-None-Match: "abc"304 + 0 байта

Спестяване: ~50 KB × брой преваличане × брой сървъри на ръба. При 10 региона Cloudflare × 60 переваличане/ч = 600 fetch/ч/публикация. 50 KB × 600 = 30 MB/ч → без ETag. С ETag → 600 × ~200 B (само заглавка) = 120 KB/ч. 250× по-малко.

Какво ТРЯБВА да отиде в ETag

Всяка промяна видима за клиента. Минимум:

  • id — различни публикации имат различни etagi дори когато updatedAt случайно идентични
  • updatedAt — bump при редактиране
  • version — в случай на каскадни промени (коментари, брой преглеждания) обикновено по-добре е да имаш отделно поле отколкото да разчиташ на updatedAt

Какво НЕ давай на ETag: хеш на пароли, секрети, данни на други потребители. ETag е публичен — попада в логовете на CDN, заглавките на браузъра.

7. Обработка на грешки в продукция (500, 502, 503, 504)

Тези четири кода изглеждат подобни, но всеки означава нещо различно и всеки има различна реакция на страната на клиента / мониторинга.

500 — твойте код е падал

Изключение в обработчик. Setup, който не течи stack trace на prod и дава requestId за корелирани:

fastify.setErrorHandler((err, req, reply) => {
  req.log.error({ err, reqId: req.id, url: req.url, method: req.method }, 'unhandled')

  // Бизнес грешки — отпусни
  if (err.statusCode && err.statusCode < 500) {
    return reply.code(err.statusCode).send({
      error: err.code || 'error',
      message: err.message,
    })
  }

  // Истинския 500 — скрий детайли
  return reply.code(500).send({
    error: 'internal_error',
    requestId: req.id,   // към логовете
    // НЕ: err.message, err.stack, err.sql, пътища до файлове
  })
})

Когато потребителят докладва „получих грешка", пита „какво е request ID?" и намираш в логовете пълния stack trace. Потребителят не вижда нищо чувствително.

502 и 504 — Nginx → бекенд

Обеопти автоматични от Nginx. 502 = бекенд върна смет (upstream падна в лета, сброс връзка, невалиден HTTP отговор). 504 = бекенд не отговори в час (proxy_read_timeout).

# /etc/nginx/sites-enabled/api
upstream api {
  server 127.0.0.1:4000 max_fails=3 fail_timeout=30s;
  server 127.0.0.1:4001 backup;
}

server {
  listen 443 ssl;
  server_name api.example.pl;

  location / {
    proxy_pass http://api;
    proxy_connect_timeout 5s;
    proxy_send_timeout 30s;
    proxy_read_timeout 30s;

    # Повторна опит на следващия upstream
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;

    # Премахни заглавките течане
    proxy_hide_header X-Powered-By;
    proxy_hide_header Server;
  }
}

503 — временно, Retry-After

503 е ЕДИНСТВЕ от 5xx, който ясно казва на клиента „опитайте отново". Използвай когато:

  • Планирана поддържане (deploy blue-green)
  • Circuit breaker отворен (зависим сервис падна)
  • Load shedding (твърде много RPS, защитавам останалите)

Обикновено с Retry-After + персонализирано тяло:

// Circuit breaker — когато DB е претоварена
fastify.addHook('preHandler', async (req, reply) => {
  if (await circuitBreaker.isOpen()) {
    return reply
      .code(503)
      .header('Retry-After', '30')
      .send({
        error: 'service_degraded',
        retryAfter: 30,
      })
  }
})

Ключова разлика 500 vs 503: 500 = бъг, SRE получава页面. 503 = очаквано, мониторинга разграничава (не алармиране). Връщането на 503 вместо 500 за да „не аларминирам" — анти-пътека. Аларми са за това, за да алармирам.

8. Референтна таблица — всички 63 кода с присвояване на RFC

Когато трябва бързо да провериш конкретен код — таблица по-долу. Всички 63 официални входове от регистъра IANA с връзка към дефиниращия RFC.

1xx — Информационни (4 кода)

КодИмеRFCКога
100ContinueRFC 9110 §15.2.1Клиентът е изпратил Expect: 100-continue, сървърът се съглася на тяло
101Switching ProtocolsRFC 9110 §15.2.2WebSocket handshake, HTTP/2 upgrade
102Processing ⚠RFC 2518 (deprecated)WebDAV, премахнато от RFC 4918
103Early HintsRFC 8297Preload намек преди окончателен отговор (Chrome 103+)

2xx — Успех (10 кода)

КодИмеRFCКога
200OKRFC 9110 §15.3.1Успех с тяло
201CreatedRFC 9110 §15.3.2Нов ресурс, + Location
202AcceptedRFC 9110 §15.3.3Асинхронна работа заредена
203Non-AuthoritativeRFC 9110 §15.3.4Отговор от трансформиран прокси
204No ContentRFC 9110 §15.3.5DELETE OK, без тяло
205Reset ContentRFC 9110 §15.3.6Нулирай формуляр (редко)
206Partial ContentRFC 9110 §15.3.7Range заявка
207Multi-StatusRFC 4918 §11.1WebDAV пакет
208Already ReportedRFC 5842 §7.1WebDAV binding, избегни дупликати
226IM UsedRFC 3229Delta encoding (редко)

3xx — Пренасочвания (9 кода)

КодИмеRFCКога
300Multiple ChoicesRFC 9110 §15.4.1Няколко представяния (редко)
301Moved PermanentlyRFC 9110 §15.4.2Трайно, GET (може да промени POST→GET)
302FoundRFC 9110 §15.4.3Временно, GET (двусмислено)
303See OtherRFC 9110 §15.4.4Post-Redirect-Get
304Not ModifiedRFC 9110 §15.4.5Кеш валидирани ETag/If-Modified-Since
305Use Proxy ⚠RFC 9110 §15.4.6 (deprecated)Security vulnerability, не използвай
306(Unused)RFC 9110 §15.4.7Заседнало
307Temporary RedirectRFC 9110 §15.4.8Временно, запазва метода
308Permanent RedirectRFC 9110 §15.4.9Трайно, запазва метода

4xx — Грешки на клиент (28 кода)

КодИмеRFCКога
400Bad RequestRFC 9110 §15.5.1Лош формат заявка
401UnauthorizedRFC 9110 §15.5.2Липса / лош жетон, + WWW-Authenticate
402Payment RequiredRFC 9110 §15.5.3Заседнало, Stripe „карта отклонена"
403ForbiddenRFC 9110 §15.5.4Жетон OK, липса разрешения
404Not FoundRFC 9110 §15.5.5Ресурс не съществува
405Method Not AllowedRFC 9110 §15.5.6+ Allow
406Not AcceptableRFC 9110 §15.5.7Съдържане преговор не отговаря
407Proxy Auth RequiredRFC 9110 §15.5.8Както 401 за прокси
408Request TimeoutRFC 9110 §15.5.9Твърде бавна испращане на тяло
409ConflictRFC 9110 §15.5.10Unique нарушение, държавна машина
410GoneRFC 9110 §15.5.11Изтрито завинаги
411Length RequiredRFC 9110 §15.5.12Липса Content-Length
412Precondition FailedRFC 9110 §15.5.13If-Match / If-Unmodified-Since
413Content Too LargeRFC 9110 §15.5.14Тяло надвишава лимит
414URI Too LongRFC 9110 §15.5.15URL твърде дълъг
415Unsupported Media TypeRFC 9110 §15.5.16Лош Content-Type
416Range Not SatisfiableRFC 9110 §15.5.17Лош Range
417Expectation FailedRFC 9110 §15.5.18Не изпълнява Expect
418I'm a teapotRFC 2324 (шега)Honeypot за скенери
421Misdirected RequestRFC 9110 §15.5.20HTTP/2 връзка коалесценция
422Unprocessable ContentRFC 9110 §15.5.21Бизнес валидирани
423LockedRFC 4918 §11.3WebDAV
424Failed DependencyRFC 4918 §11.4WebDAV
425Too EarlyRFC 8470TLS 1.3 0-RTT преиграване защита
426Upgrade RequiredRFC 9110 §15.5.22Наложи по-нов протокол
428Precondition RequiredRFC 6585 §3Наложи If-Match (загубена актуализиране)
429Too Many RequestsRFC 6585 §4Честотна лимит, + Retry-After
431Request Headers Too LargeRFC 6585 §5Заглавки надвишават лимит
451Unavailable For Legal ReasonsRFC 7725RODO, DMCA, геоблокиране

5xx — Грешки на сървър (11 кода)

КодИмеRFCКога
500Internal Server ErrorRFC 9110 §15.6.1Изключение в кода
501Not ImplementedRFC 9110 §15.6.2Неизвестен HTTP метод
502Bad GatewayRFC 9110 §15.6.3Прокси получи смет от бекенда
503Service UnavailableRFC 9110 §15.6.4Временно, + Retry-After
504Gateway TimeoutRFC 9110 §15.6.5Прокси → бекенд timeout
505HTTP Version Not SupportedRFC 9110 §15.6.6Лош HTTP/X
506Variant Also NegotiatesRFC 2295 §8.1Грешка съдържане преговор (редко)
507Insufficient StorageRFC 4918 §11.5Липса място на диск (качване)
508Loop DetectedRFC 5842 §7.2WebDAV рекурсия
510Not Extended ⚠RFC 2774 (deprecated)HTTP Extension Framework
511Network Authentication RequiredRFC 6585 §6Плен портал (WiFi хотел/летище)

Източники

  1. IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
  2. R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, Юни 2022, rfc-editor.org/rfc/rfc9110
  3. L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, Юни 2007, rfc-editor.org/rfc/rfc4918
  4. M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, Април 2012, rfc-editor.org/rfc/rfc6585
  5. T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, Февруари 2016
  6. K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, Декември 2017
  7. CVE-2011-3192, Apache HTTP Server Range Header DoS (Apache Killer), nvd.nist.gov
  8. OWASP, Authentication Cheat Sheet, cheatsheetseries.owasp.org
  9. OWASP, Unvalidated Redirects and Forwards, cheatsheetseries.owasp.org
  10. J. Kettle, Practical Web Cache Poisoning, PortSwigger Research 2018
  11. Fastify, Reply API documentation, fastify.dev/docs
  12. Node.js, BullMQ job queue documentation, docs.bullmq.io
HTTP кодове — пълно ръководство за програмисти: всички статуси, приложения и безопасни капани — PageForYou.pl