Programowanie April 24, 2026 28 min

HTTP Codes — complete guide for programmers: all statuses, applications and security pitfalls

Most HTTP status code documents are lists of 63 items with one-line definitions. Little comes from that. This document goes the opposite way — seven real scenarios that you write in the backend, each with complete working Fastify + Prisma code, shown in two versions: with security bugs and without. HTTP status codes appear where they always appear — in context. At the end a reference table of all 63 codes with RFC assignments — when you need to search by number.

1. REST API — CRUD for posts

We start with the most common case. We write an endpoint managing blog posts. Naturally, eight HTTP codes will appear in it: 200, 201, 204, 304, 400, 404, 409, 422. Each in its own place, not because "we had to use it", but because it solves a concrete problem.

Full endpoint — 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) {

  // LIST — 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
  })

  // READ — GET /posts/:id (with ETag and 304 validation)
  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()                     // without body
    }
    return reply.header('ETag', currentEtag).send(post) // 200 OK with ETag
  })

  // CREATE — POST /posts
  fastify.post('/posts', async (req, reply) => {
    const parsed = CreatePostSchema.safeParse(req.body)
    if (!parsed.success) {
      return reply.code(400).send({                     // 400 — bad structure
        error: 'invalid_body',
        issues: parsed.error.flatten(),
      })
    }
    const data = parsed.data

    // Business validation — 422, because format was OK
    if (data.publishAt && new Date(data.publishAt) < new Date()) {
      return reply.code(422).send({
        error: 'publish_at_in_past',
        message: 'publishAt must be in the future',
      })
    }

    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
    }
  })

  // UPDATE — PATCH /posts/:id (optimistic concurrency via 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' })
    }

    // Optimistic lock — client sent If-Match with 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: 'Someone changed the post in the meantime — fetch the current version',
      })
    }

    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 — 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
  })
}

Why these codes and not others — decision by decision

Why 201 + Location instead of 200

200 OK says "the operation succeeded". 201 Created says "it succeeded and a new entity was created". The difference is practical: a client receiving 201 with Location: /posts/abc knows exactly that it can do GET /posts/abc and get the newly created resource. A client receiving 200 must extract the ID from the body — which requires a contract. A contract can change and break clients; Location is a standard.

Why 204 for DELETE instead of 200

Soft delete succeeded, we have nothing meaningful to return — 204 No Content. RFC 9110 forbids a body with 204, so fetch() in the browser automatically treats this response as "no body" and doesn't try to parse JSON. Returning 200 with {"deleted":true} forces the client to handle such a format; 204 is the standard.

Why I distinguish 400 and 422

Zod returns two types of errors. "Missing title field" — syntax, the request format is bad, 400 Bad Request. "publishAt in the past" — the format is OK (it's a valid ISO date), but the domain logic refuses. This is 422 Unprocessable Content. GitHub, Stripe, Rails consistently do this — and it makes sense, because the client treats 400 as a bug (we're structuring the request wrong), and 422 as a refusal (the user is trying to do something illegal in business terms).

Why 409 for unique constraint

Prisma throws P2002 when two users simultaneously insert a post with the same slug. Code 400 would be misleading — the request was correct. 422 would be odd — it's not a business rule, it's a state conflict. 409 Conflict is exactly that: "I can't do this because it collides with the current state".

Why 412 for optimistic lock

If-Match is a precondition. When it fails, the specification (RFC 9110 §13.1.1) says clearly: 412 Precondition Failed. Not 409 — the client isn't in a state of conflict, it got into one because it has an old version.

Why 304 instead of re-serving the entire response

A client with ETag "abc123" sends If-None-Match: "abc123". If the post hasn't changed — we return 304, empty body. This saves bandwidth (the post might be 50 KB of content), saves JSON serialization, saves gzip. Cloudflare/Varnish understand 304 and don't try to cache it as a response. In a typical CMS — 80%+ of requests to published posts can end with 304.

Test verifying decisions

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

test('POST /posts returns 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 with publishAt in the past → 422 (not 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',  // past
    },
  })
  expect(res.statusCode).toBe(422)   // semantics, not syntax
  expect(res.json().error).toBe('publish_at_in_past')
})

test('DELETE returns 204 without body', async () => {
  const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
  expect(res.statusCode).toBe(204)
  expect(res.body).toBe('')         // empty string, not "{}"
})

test('GET with If-None-Match returns 304 without body', 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 with stale If-Match → 412', async () => {
  const res = await app.inject({
    method: 'PATCH', url: '/posts/abc',
    headers: { 'if-match': '"old-version"' },
    payload: { title: 'New' },
  })
  expect(res.statusCode).toBe(412)
})

2. Login and sessions — 4 real bugs, 4 fixes

A login endpoint is one place where four different security bugs reveal themselves through unremarkable HTTP code / response shape / logging decisions. Below is the full path: first the version with bugs (exactly the kind we write on the first iteration), then each bug pulled onto the table and fixed, then the final version with a test.

Version with bugs (DON'T USE)

// apps/api/src/routes/auth/login.ts — version with 4 bugs
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')   // BUG #4

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

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

    const attempts = await redis?.incr(`login:${email}`).catch(() => null)
    if (attempts === null) {                              // BUG #3
      // Redis is down — allow further, security < availability
    } else if (attempts > 5) {
      return reply.code(429).send({ error: 'too_many' })
    }

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

Bug #1 — username enumeration via different codes

The endpoint returns 404 user_not_found when the email doesn't exist, 401 wrong_password when it does but the password is wrong. An attacker with a list of 500k emails (leak from another service) will filter in a few hours all active accounts of your company. HTTP statuses go to proxies/CDNs/access logs — no need to even read the body.

Fix:

// DUMMY_HASH — precomputed bcrypt hash of string "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' })
}

Now the attacker gets identical 401 invalid_credentials regardless of whether the account exists. There's no way to tell.

Bug #2 — timing attack

The fix from #1 only apparently solved the problem. Reading it carefully — when user doesn't exist, hashToCompare = DUMMY_HASH, bcrypt.compare takes ~80 ms. When user exists, bcrypt compares with their real hash — also ~80 ms (as long as the cost is the same). OK, in this specific fragment timing is equalized.

But note: in the original buggy version, the first branch if (!user) return ... executes in ~2 ms (just a database query), the second ~82 ms (query + bcrypt). An attacker measures average time from 100 tries:

// attacker.ts — shows the difference
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

A difference of 80 ms is an eternity — the attacker enumerates accounts without looking at HTTP status. The fix requires dummy bcrypt.compare for non-existent users (see the code above — it's already correct). Additionally, a regression test:

test('login does not reveal user existence through timing', async () => {
  const warmup = 10, samples = 200

  // warmup
  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)   // less than 5ms difference
})

Bug #3 — fail-open rate limit

redis?.incr(...).catch(() => null) — when Redis goes down, attempts === null, the condition attempts > 5 is false, limit disabled. Real incident (from an audit at PageForYou.pl in April 2026): OOM on Redis + attacker at the same moment + 200k password attempts before anyone noticed.

Fix — fail closed:

const key = `login:${email}:${req.ip}`
let attempts: number
try {
  attempts = await redis.incr(key)
  if (attempts === 1) await redis.expire(key, 60)   // sliding window 1 min
} catch {
  // Redis is down — do NOT allow further
  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' })
}

Trade-off, conscious: when Redis goes down nobody can log in for ~30 s (Redis recovery time + expire). We accept this — security > availability. The alternative is a local fallback (in-memory map), but then a distributed system has uncoordinated limit — each pod counts separately, in reality N× 5 attempts instead of 5.

Bug #4 — password in logs

req.log.info({ email, password }, ...) → Loki/ELK/Splunk get the password in plaintext. Everyone with access to monitoring (SRE, DevOps, contractor) will see it. Classic leak.

Fix — 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]',
    },
  },
})

Redact works at the Pino serializer level — passwords never reach the transport, even by accident in other parts of the code.

Final version of the endpoint

// apps/api/src/routes/auth/login.ts — after all 4 fixes
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. Rate limit (fail-closed)
    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. Constant-time user lookup + compare
    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' })
    }

    // Success — reset rate limit + issue token
    await redis.del(rateKey).catch(() => {})
    const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
    return reply.send({ token, expiresIn: 900 })
  })
}

Why 401 and not 403 for failed login

Classic mistake: if the user entered "wrong password", maybe 403 Forbidden? No. 401 means "I don't know who you are, authenticate yourself". 403 means "I know who you are, but you don't have permission to this resource". In login, authentication failed — 401. I use 403 only for disabled account, where identity was OK, but policy refuses further.

3. Redirects — 5 scenarios without shooting yourself in the foot

Five different redirects, each with a different reason. Choosing the wrong code changes POST to GET, breaks SEO, or opens an open redirect.

Scenario 1: HTTP → HTTPS (global)

Code: 301 + HSTS. Permanent, for all methods (GET dominates; for POST it's the same fix, browser will do what it needs to).

# /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;
  # ...
}

Pitfall: the first HTTP request may contain session cookies in plaintext. HSTS solves this only from the second visit — after the first successful HTTPS response the browser remembers and no longer tries HTTP. The first visit must be either via explicit https:// or preload list (hstspreload.org).

Scenario 2: Old URL → new one after rebranding

Code: 301. Google will deindex the old URL and rewrite page rank.

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

Pitfall: 301 is aggressively cached by the browser (sometimes forever). If you mess up the target URL, users will land there even after you fix it, as long as they don't clear cache. If you're not 100% sure — use 302 at first, change to 301 when you're certain.

Scenario 3: Post-Redirect-Get after form

Code: 303 See Other. Forces GET instead of POST — F5 on the confirmation page doesn't cause duplicate order.

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

Difference from 302: 302 historically meant "preserve the method", but browsers always changed POST to GET. RFC 9110 documented this inconsistency. 303 is explicitly about this — no ambiguity.

Scenario 4: Permanent API endpoint change with POST

Code: 308 Permanent Redirect. Unlike 301, it guarantees method and body preservation.

// Old endpoint /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
  return reply.redirect(308, '/api/v2/orders')
})

A client that sent POST with 2 KB body will get 308 Location: /api/v2/orders and execute POST with the same body. 301 is NOT SAFE here — some HTTP libraries (older curl, old axios) will change POST to GET.

Scenario 5: Open redirect — classic phishing attack

The most common application vulnerability I see on audit. Click tracking endpoint:

// VULNERABLE — DON'T USE
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
  return reply.redirect(302, req.query.url)
})

Attack: SMS "Your bank.pl account will be blocked, verify: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Victim sees trusted domain, clicks, gets 302 Location: https://bank-login-verify.evil/auth, lands on phishing. Entering password = account compromised.

Fix — allowlist:

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' })
  }

  // Only HTTPS, only known hosts
  if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
    return reply.code(400).send({ error: 'domain_not_allowed' })
  }

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

Choice of 302 (not 301) is deliberate — we don't want the browser to cache "click here → land there", it should be a fresh decision with every click.

4. Upload and range requests — including Apache Killer

File upload is where 413 (file too large), 415 (wrong type), 416 (invalid range) appear and 206 (partial content — video with seeking). Plus Apache Killer — a 15-year-old attack that still works on misconfigured servers.

Upload endpoint with validation

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 check — multipart limit might be bypassed, check real size
    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 bytes check — mimetype from header might lie
    const realMime = await detectMimeFromBytes(path)   // e.g. file-type lib
    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 })
  })
}

Why magic bytes check — don't trust the client

data.mimetype is the Content-Type from the multipart header — the client can write any string there. An attacker will send .exe with Content-Type: image/png, and later in another part of the app that file gets served to img src — which is safe — but to iframe or Content-Type in the response, and the browser executes the binary. Verification via file-type/libmagic reads the first bytes and really recognizes the type.

Range requests — serving video with seeking

A video player sends Range: bytes=1048576-2097151 — wants the middle fragment. Server responds 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, whole thing
  }

  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

An attacker sends a header with thousands of overlapping ranges:

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

The server tries to prepare and combine all these fragments — OOM, crash. The fix in Apache/Nginx was settled long ago, but if you write your own Range handling (above) — you must limit the number and size of ranges. My handler accepts only a single range, which is safe. Full RFC 7233 supports multipart/byteranges with multiple ranges — only implement if you really need it, with a max 10 ranges limit.

5. Async jobs — 202 Accepted + polling

A GDPR export ZIP is ~62 seconds on a large account (PageForYou GDPR export). Synchronously — the browser times out, Cloudflare 524. Correctly — 202 Accepted + status endpoint + client-side polling.

Endpoint creating a job

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}`,
    })
})

Status endpoint — what to return, depending on stage

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' })

  // Authorization — only the job owner
  if (job.data.userId !== req.user.id) {
    return reply.code(404).send({ error: 'job_not_found' })   // 404 deliberately, not 403
  }

  const state = await job.getState()

  if (state === 'completed') {
    // Ready — redirect to 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,
    })
  }

  // Still running
  return reply
    .code(200)
    .header('Retry-After', '5')    // hint to client — poll in 5s
    .send({
      jobId: job.id,
      status: state,                 // waiting, active, delayed
      progress: job.progress,
    })
})

Why 404 instead of 403 for someone else's job

An attacker knowing the job ID format could enumerate /exports/0, /exports/1, ... and by status codes (403 vs 404) map other users' activity. By always returning 404 when the job isn't yours — the attacker can't tell "doesn't exist" from "not yours".

Why 303 after completion, not 200

A client asking about status wants the URL to download when done. 303 See Other with Location: signed-url says "go there via GET". The client can follow-redirect automatically, the user gets the file. 200 with { downloadUrl: "..." } requires the client to make a separate GET and separate logic — 303 is cleaner.

6. Cache validation — ETag and 304

A complete example of cache validation in a blog API, like the one you're reading. Goal: reduce bandwidth 10×, lower SSR render time.

Endpoint with ETag and validation

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, /* and other fields */
              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']

  // Cache validation — does client have current version?
  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()                                      // empty body
  }

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

How Next.js / Cloudflare uses this

Next.js ISR with revalidate: 60 will fetch your API every 60 s. With ETag:

  • First fetch → 200 OK + ETag + body (50 KB)
  • Subsequent revalidation fetch (every 60 s) → If-None-Match: "abc"304 + 0 bytes

Savings: ~50 KB × number of revalidations × number of servers. With 10 Cloudflare regions × 60 revalidations/h = 600 fetches/h/post. 50 KB × 600 = 30 MB/h → without ETag. With ETag → 600 × ~200 B (just the header) = 120 KB/h. 250× less.

What MUST go into ETag

Every change observable by the client. Minimum:

  • id — different posts have different etags even if updatedAt happens to be identical
  • updatedAt — bump on edit
  • version — in case of cascading changes (comments, view counter) usually better to have separate field than rely on updatedAt

What NOT to put in ETag: password hash, secrets, other users' data. ETag is public — goes to CDN logs, browser headers.

7. Error handling in production (500, 502, 503, 504)

These four codes look similar, but each means something different and each has a different client / monitoring reaction.

500 — your code crashed

Exception in handler. Setup that doesn't leak stack trace in prod and gives requestId for correlation:

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

  // Business errors — pass through
  if (err.statusCode && err.statusCode < 500) {
    return reply.code(err.statusCode).send({
      error: err.code || 'error',
      message: err.message,
    })
  }

  // Real 500 — hide details
  return reply.code(500).send({
    error: 'internal_error',
    requestId: req.id,   // for logs
    // NOT: err.message, err.stack, err.sql, file paths
  })
})

When a user reports "I got an error", you ask "what's the request ID?" and find the full stack trace in logs. The user sees nothing sensitive.

502 and 504 — Nginx → backend

Both automatic from Nginx. 502 = backend returned garbage (upstream crashed mid-request, reset connection, invalid HTTP response). 504 = backend didn't respond in time (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;

    # Retry on next upstream
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries 2;

    # Strip leak headers
    proxy_hide_header X-Powered-By;
    proxy_hide_header Server;
  }
}

503 — temporary, Retry-After

503 is the ONLY one of the 5xx that clearly tells the client "try again". Use when:

  • Planned maintenance (blue-green deploy)
  • Circuit breaker open (dependent service is down)
  • Load shedding (too much RPS, protecting the rest)

Usually with Retry-After + custom body:

// Circuit breaker — when DB is overloaded
fastify.addHook('preHandler', async (req, reply) => {
  if (await circuitBreaker.isOpen()) {
    return reply
      .code(503)
      .header('Retry-After', '30')
      .send({
        error: 'service_degraded',
        retryAfter: 30,
      })
  }
})

Key difference 500 vs 503: 500 = bug, SRE gets paged. 503 = expected, monitoring distinguishes (no alerting). Returning 503 instead of 500 to "not alarm" — an anti-pattern. Alarms are there to alarm.

8. Reference table — all 63 codes with RFC assignment

When you need to quickly check a specific code — the table below. All 63 official entries from the IANA registry with a reference to the defining RFC.

1xx — Informational (4 codes)

CodeNameRFCWhen
100ContinueRFC 9110 §15.2.1Client sent Expect: 100-continue, server agrees to body
101Switching ProtocolsRFC 9110 §15.2.2WebSocket handshake, HTTP/2 upgrade
102Processing ⚠RFC 2518 (deprecated)WebDAV, removed from RFC 4918
103Early HintsRFC 8297Preload hint before final response (Chrome 103+)

2xx — Success (10 codes)

CodeNameRFCWhen
200OKRFC 9110 §15.3.1Success with body
201CreatedRFC 9110 §15.3.2New resource, + Location
202AcceptedRFC 9110 §15.3.3Async job queued
203Non-AuthoritativeRFC 9110 §15.3.4Response from transforming proxy
204No ContentRFC 9110 §15.3.5DELETE OK, no body
205Reset ContentRFC 9110 §15.3.6Reset form (rare)
206Partial ContentRFC 9110 §15.3.7Range request
207Multi-StatusRFC 4918 §11.1WebDAV batch
208Already ReportedRFC 5842 §7.1WebDAV binding, avoid duplicates
226IM UsedRFC 3229Delta encoding (rare)

3xx — Redirects (9 codes)

CodeNameRFCWhen
300Multiple ChoicesRFC 9110 §15.4.1Multiple representations (rare)
301Moved PermanentlyRFC 9110 §15.4.2Permanent, GET (may change POST→GET)
302FoundRFC 9110 §15.4.3Temporary, GET (ambiguous)
303See OtherRFC 9110 §15.4.4Post-Redirect-Get
304Not ModifiedRFC 9110 §15.4.5Cache validation ETag/If-Modified-Since
305Use Proxy ⚠RFC 9110 §15.4.6 (deprecated)Security vulnerability, don't use
306(Unused)RFC 9110 §15.4.7Reserved
307Temporary RedirectRFC 9110 §15.4.8Temporary, preserves method
308Permanent RedirectRFC 9110 §15.4.9Permanent, preserves method

4xx — Client errors (28 codes)

CodeNameRFCWhen
400Bad RequestRFC 9110 §15.5.1Wrong request format
401UnauthorizedRFC 9110 §15.5.2Missing/wrong token, + WWW-Authenticate
402Payment RequiredRFC 9110 §15.5.3Reserved, Stripe "card declined"
403ForbiddenRFC 9110 §15.5.4Token OK, no permission
404Not FoundRFC 9110 §15.5.5Resource doesn't exist
405Method Not AllowedRFC 9110 §15.5.6+ Allow
406Not AcceptableRFC 9110 §15.5.7Content negotiation doesn't match
407Proxy Auth RequiredRFC 9110 §15.5.8Like 401 for proxy
408Request TimeoutRFC 9110 §15.5.9Slow body sending
409ConflictRFC 9110 §15.5.10Unique violation, state machine
410GoneRFC 9110 §15.5.11Deleted forever
411Length RequiredRFC 9110 §15.5.12Missing Content-Length
412Precondition FailedRFC 9110 §15.5.13If-Match / If-Unmodified-Since
413Content Too LargeRFC 9110 §15.5.14Body exceeds limit
414URI Too LongRFC 9110 §15.5.15URL too long
415Unsupported Media TypeRFC 9110 §15.5.16Wrong Content-Type
416Range Not SatisfiableRFC 9110 §15.5.17Wrong Range
417Expectation FailedRFC 9110 §15.5.18Doesn't meet Expect
418I'm a teapotRFC 2324 (joke)Honeypot for scanners
421Misdirected RequestRFC 9110 §15.5.20HTTP/2 connection coalescing
422Unprocessable ContentRFC 9110 §15.5.21Business logic validation
423LockedRFC 4918 §11.3WebDAV
424Failed DependencyRFC 4918 §11.4WebDAV
425Too EarlyRFC 8470TLS 1.3 0-RTT replay protection
426Upgrade RequiredRFC 9110 §15.5.22Enforce newer protocol
428Precondition RequiredRFC 6585 §3Enforce If-Match (lost update)
429Too Many RequestsRFC 6585 §4Rate limit, + Retry-After
431Request Headers Too LargeRFC 6585 §5Headers exceeded limit
451Unavailable For Legal ReasonsRFC 7725GDPR, DMCA, geoblock

5xx — Server errors (11 codes)

CodeNameRFCWhen
500Internal Server ErrorRFC 9110 §15.6.1Exception in code
501Not ImplementedRFC 9110 §15.6.2Unknown HTTP method
502Bad GatewayRFC 9110 §15.6.3Proxy got garbage from backend
503Service UnavailableRFC 9110 §15.6.4Temporary, + Retry-After
504Gateway TimeoutRFC 9110 §15.6.5Proxy → backend timeout
505HTTP Version Not SupportedRFC 9110 §15.6.6Wrong HTTP/X
506Variant Also NegotiatesRFC 2295 §8.1Content negotiation error (rare)
507Insufficient StorageRFC 4918 §11.5No disk space (upload)
508Loop DetectedRFC 5842 §7.2WebDAV recursion
510Not Extended ⚠RFC 2774 (deprecated)HTTP Extension Framework
511Network Authentication RequiredRFC 6585 §6Captive portal (WiFi hotel/airport)

Sources

  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, June 2022, rfc-editor.org/rfc/rfc9110
  3. L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, June 2007, rfc-editor.org/rfc/rfc4918
  4. M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, April 2012, rfc-editor.org/rfc/rfc6585
  5. T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, February 2016
  6. K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, December 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 Codes — complete guide for programmers: all statuses, applications and security pitfalls — PageForYou.pl