Kamil Owczarek
Opublikowano

Pułapka payloadu w API: dlaczego twój serwer nie powinien dotykać bajtów

Autorzy

Problem z proxowaniem

Jeśli twoje API serwuje duże odpowiedzi — katalogi produktów, feedy danych, pliki eksportów — pewnie zbudowałeś coś takiego:

ClientAPI Server (auth + fetch from storage) → stream response back

Serwer API uwierzytelnia request, pobiera dane ze storage'u lub CDN i przekazuje każdy bajt z powrotem do klienta. Działa, ale ma problemy, które narastają wraz z ruchem.

Stosowaliśmy ten wzorzec przy naszych feedach B2B. Klienci prosili o katalogi produktów (pliki JSON/XML o rozmiarze 500 KB–2 MB), a nasz serwer pobierał plik ze storage'u CDN i streamował go z powrotem. Wciąż wracały trzy problemy:

Niezgodność Content-Encoding. Gdy twój serwer pobiera plik z CDN, wiele klientów HTTP automatycznie dekompresuje odpowiedzi gzip. Ale oryginalny nagłówek Content-Length nadal opisuje rozmiar skompresowany. Klient dostaje 800 KB danych, a nagłówek mówi 300 KB — część klientów HTTP ucina odpowiedź na 300 KB.

Ucięty strumień. Jeśli CDN zerwie połączenie w trakcie streamowania, twój serwer wysłał już HTTP 200 razem z nagłówkami. Klient dostaje niepełną odpowiedź ze statusem sukcesu. Po wysłaniu nagłówków nie ma już jak zasygnalizować błędu.

Niepotrzebny compute. Twój serwer pali cykle CPU i transfer tylko po to, żeby przekazywać bajty między dwoma innymi systemami. Na platformach serverless płacisz za każdą milisekundę tego przekazywania.

Wzorzec: redirect zamiast proxy

Fix jest architektonicznie prosty: w ogóle nie proxuj bajtów. Zamiast tego wygeneruj podpisany URL, który daje tymczasowy dostęp do pliku na CDN, i przekieruj tam klienta.

ClientAPI Server (auth + generate signed URL)307 Redirect
ClientCDN Edge (direct download)

Twój serwer zajmuje się uwierzytelnianiem, rate limitingiem i kontrolą dostępu — a potem schodzi z drogi. CDN zajmuje się dostarczaniem treści, kompresją i globalną dystrybucją na edge.

To nie jest nowatorski pomysł. To dominujący wzorzec przy dużej skali.

Kto stosuje ten wzorzec

Netflix — Open Connect

Netflix zbudował wokół tej koncepcji cały CDN (Open Connect). Ich serwery API obsługują control plane — uwierzytelnianie, wybór treści, decyzje o bitrate — a potem kierują klientów do najbliższego Open Connect Appliance po samo wideo. API nigdy nie dotyka bajtów wideo. Według zespołu inżynierskiego Netflixa 95% ich globalnego ruchu jest serwowane bezpośrednimi połączeniami między ich urządzeniami na edge a sieciami ISP.

Spotify — rozdzielenie control plane i data plane

Architektura Spotify jawnie rozdziela control plane od data plane. Bezstanowe serwery API obsługują uwierzytelnianie JWT i generują podpisane URL-e. Klienci streamują potem audio bezpośrednio z najbliższego node'a CDN na edge. Ich blog inżynierski opisuje to rozdzielenie jako klucz do obsługi miliardów odtworzeń bez zamieniania serwerów API w wąskie gardło.

Docker Registry — 307 zapisane w specyfikacji

Docker Registry HTTP API V2 używa redirectów 307 na poziomie specyfikacji. Gdy robisz docker pull, requesty po blob-y zwracają redirecty 307 do backendów storage'owych (S3, Azure Blob Storage) zamiast serwować treść przez registry. Wszyscy klienci Dockera muszą wspierać redirecty przy requestach o blob-y. To jeden z najczystszych przykładów tego wzorca zapisanego jako standard API.

GitHub — presigned URL-e do S3

GitHub stosuje to samo podejście przy pobieraniu plików. URL-e do assetów zwracają redirecty do presigned URL-i AWS S3 z wbudowanymi parametrami uwierzytelniającymi. Serwery GitHuba nigdy nie proxują bajtów plików.

AWS S3 — wzorzec kanoniczny

AWS dokumentuje to jako architekturę kanoniczną: funkcja Lambda generuje presigned URL i odpowiada redirectem. Klient pobiera plik prosto z S3. AWS opisuje to tak: „Plik idzie bezpośrednio z przeglądarki do S3 — twoje API generuje tylko przepustkę”.

Jak działają podpisane URL-e

Podpisany URL to zwykły URL z parametrami kryptograficznymi, które dowodzą, że serwer autoryzował dostęp. CDN weryfikuje podpis, zanim wyda plik.

Podpis wylicza się zwykle tak:

import { createHash } from 'node:crypto';

function generateSignedUrl(path: string, expirationSeconds = 60): string {
    const securityKey = process.env.CDN_SECURITY_KEY;
    const hostname = process.env.CDN_HOSTNAME;

    const cleanPath = path.startsWith('/') ? path : `/${path}`;
    const expires = Math.floor(Date.now() / 1000) + expirationSeconds;

    const token = createHash('sha256')
        .update(securityKey + cleanPath + expires)
        .digest('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');

    return `https://${hostname}${cleanPath}?token=${token}&expires=${expires}`;
}

CDN odbiera request, przelicza hash swoją kopią klucza bezpieczeństwa i porównuje. Jeśli hash się zgadza, a termin ważności nie minął, wydaje plik. W przeciwnym razie zwraca 403.

Kluczowe właściwości:

  • Ograniczone w czasie: URL-e wygasają po ustalonym okresie (zwykle 30–300 sekund)
  • Przypisane do ścieżki: podpis jest związany z konkretną ścieżką pliku
  • Odporne na manipulację: zmiana dowolnego parametru unieważnia podpis
  • Bezstanowe: CDN nie musi wołać z powrotem do twojego serwera, żeby zweryfikować dostęp

Większość dostawców CDN to wspiera: AWS CloudFront używa par kluczy RSA, Google Cloud CDN korzysta z HMAC-SHA1, Azure CDN ma autoryzację tokenową opartą na HMAC, a Akamai oferuje swój system Auth Token 2.0.

Dlaczego 307 (a nie 302)

HTTP ma kilka kodów przekierowań. Dla redirectów w API właściwym wyborem jest 307 Temporary Redirect:

KodMetoda zachowanaZastosowanie
301Brak gwarancjiTrwałe zmiany URL-i
302Brak gwarancjiLegacy, niejednoznaczne zachowanie
307Tak, ściśleTymczasowy redirect, metoda zachowana
308Tak, ściśleTrwały redirect, metoda zachowana

Kluczowa różnica: 302 formalnie pozwala klientom zmienić metodę HTTP (część starych klientów zamienia POST na GET). 307 zachowuje metodę ściśle. Dla endpointów obsługujących tylko GET oba działają identycznie, ale 307 jest semantycznie poprawne i omija edge case'y z niestandardowymi implementacjami klientów.

Wszystkie nowoczesne biblioteki HTTP domyślnie podążają za redirectami 307 — w tym cURL (przez biblioteki w rodzaju Guzzle), requests w Pythonie, fetch/axios w Node.js, HttpURLConnection w Javie i HttpClient w .NET.

Implementacja

Oto kompletny wzorzec endpointu API, który uwierzytelnia, sprawdza cache i przekierowuje:

export default defineEventHandler(async (event) => {
    // 1. Authenticate the request
    const apiKey = getQuery(event).key;
    const client = await validateApiKey(apiKey);

    if (!client) {
        throw createError({ statusCode: 403, message: 'Invalid API key' });
    }

    // 2. Rate limiting
    if (client.requestCount >= client.hourlyLimit) {
        throw createError({ statusCode: 429, message: 'Rate limit exceeded' });
    }

    // 3. Determine the file path on CDN
    const filePath = buildFilePath(client, event);

    // 4. Check if a fresh version exists on CDN
    const signedUrl = generateSignedUrl(filePath);

    const [cacheTimestamp, lastInvalidation, fileExists] = await Promise.all([
        storage.getItem(`cache:ts:${filePath}`),
        storage.getItem('cache:invalidation'),
        fetch(signedUrl, { method: 'HEAD' }).then(r => r.ok).catch(() => false),
    ]);

    // 5. Redirect if cache is fresh and file exists
    if (cacheTimestamp && cacheTimestamp > lastInvalidation && fileExists) {
        // Log usage in background (don't block the response)
        waitUntil(logUsage(client, { cacheHit: true }));
        return sendRedirect(event, signedUrl, 307);
    }

    // 6. Cache miss — generate fresh data
    const data = await generateFreshData(client, event);

    // Upload to CDN in background for next request
    waitUntil(uploadToCDN(filePath, data));

    // Log usage
    waitUntil(logUsage(client, { cacheHit: false }));

    return data;
});

Sprawdzenie HEAD

Zanim przekierujemy, weryfikujemy requestem HEAD, że plik faktycznie istnieje na CDN. Leci to równolegle z odczytem znaczników czasu cache'a — w happy path nie dokłada żadnej latencji.

Po co to? Znaczniki czasu cache'a potrafią przeżyć same pliki. Storage może wyczyścić stare pliki, deploy może zresetować storage, a poprzedni upload mógł się nie powieść. Bez sprawdzenia HEAD przekierowywałbyś klientów na 404.

const [cacheTimestamp, lastInvalidation, fileExists] = await Promise.all([
    storage.getItem(`cache:ts:${filePath}`),
    storage.getItem('cache:invalidation'),
    fetch(signedUrl, { method: 'HEAD' }).then(r => r.ok).catch(() => false),
]);

Jeśli sprawdzenie HEAD się nie powiedzie, kod leci dalej ścieżką cache missu — handler generuje świeże dane i zwraca je bezpośrednio, wysyłając je równocześnie na CDN w tle.

Inwalidacja cache'a

Wzorzec działa ramię w ramię z inwalidacją cache'a opartą na znacznikach czasu:

  1. Gdy dane się zmieniają, ustaw w swoim store'ze cache'a znacznik czasu inwalidacji
  2. Kolejny request porównuje znacznik czasu pliku ze znacznikiem inwalidacji
  3. Jeśli plik jest nieaktualny, wpada w ścieżkę regeneracji
  4. Świeże dane lądują na CDN, nadpisując stary plik
// Trigger invalidation (e.g., after stock update)
await storage.setItem('cache:invalidation', Date.now());

// Next API request:
// cacheTimestamp (old) < lastInvalidation (new) → regenerate

Plik na CDN zostaje nadpisany przy uploadzie, a większość CDN-ów sama czyści swój cache na edge, gdy treść w storage'u się zmienia.

Blokada rebuildu (ochrona przed thundering herd)

Gdy cache jest zimny albo unieważniony, wiele równoległych requestów może naraz odpalić kosztowny handler. Zapobiega temu prosta blokada:

const lockKey = `lock:${filePath}`;
const existingLock = await storage.getItem(lockKey);

if (existingLock) {
    // Another request is already rebuilding
    if (fileExists) {
        // Stale file exists — redirect to it while rebuild happens
        return sendRedirect(event, signedUrl, 307);
    }
    // No file at all — execute handler but skip upload
    return await generateFreshData(client, event);
}

// First request — acquire lock and rebuild
await storage.setItem(lockKey, Date.now(), { ttl: 30 });
const data = await generateFreshData(client, event);

// Upload and release lock in background
waitUntil(async () => {
    try {
        await uploadToCDN(filePath, data);
        await storage.setItem(`cache:ts:${filePath}`, Date.now());
    } finally {
        await storage.removeItem(lockKey);
    }
});

return data;

Blokada ma krótki TTL (30 sekund) jako zabezpieczenie — jeśli proces padnie, blokada wygaśnie sama, zamiast blokować wszystkie kolejne requesty.

Co CDN załatwia za ciebie

Kiedy już przekierujesz na CDN, robi on wszystko to, co wcześniej robiło proxy (i robi to lepiej):

ZagadnieniePodejście z proxyPodejście z redirectem do CDN
Content-TypeUstawiany ręcznie per formatWnioskowany z rozszerzenia pliku
KompresjaMusisz sam obsłużyć gzip/brotliAutomatyczna na edge, per klient
Content-LengthPodatny na błędy przy skompresowanych strumieniachZawsze poprawny
Dystrybucja globalnaJeden region originSerwowane z najbliższego edge
Koszt transferuPłacony przy każdym requeścieZero przez origin
Ryzyko niepełnej odpowiedziRealne, trudne do wykryciaCDN obsługuje ponowienia

Dla plików XML i JSON CDN-y wnioskują poprawny Content-Type z rozszerzenia pliku (.jsonapplication/json, .xmlapplication/xml). Kompresja jest negocjowana między edge'em CDN a klientem na podstawie Accept-Encoding — twój serwer w ogóle nie dotyka kodowania treści.

Wpływ na wydajność

Liczby mówią same za siebie:

MetrykaWzorzec z proxyWzorzec z redirectem
Transfer na origin (trafienie w cache)500 KB–2 MB na request~0 (tylko odpowiedź z redirectem)
Czas odpowiedzi (trafienie w cache)200–500 ms (fetch + streaming)50–100 ms (uwierzytelnianie + redirect)
Czas compute serverlessPełny czas trwania requestuTylko sprawdzenie uwierzytelnienia
Tryby awariiUcięty strumień, niezgodność encodingu, timeout CDNŻadne po stronie origin

Zespół inżynierski Fastly udokumentował, że poprawa offloadu na CDN o 5% może oznaczać spadek obciążenia origin o 50%. Przy 90% offloadu przejście na 95% nie brzmi imponująco — a połowi liczbę requestów uderzających w twoje serwery.

Warner Bros. Discovery raportowało offload origin powyżej 95% dla treści on-demand po wdrożeniu tego wzorca z Media CDN od Google, opisując integrację jako „zaskakująco bezproblemową”.

Kompromisy

Ten wzorzec nie jest darmowy. Oto realne kompromisy:

Okno obejścia rate limitu. Gdy podpisany URL zostanie już wydany, można go używać wielokrotnie w oknie ważności, omijając twój rate limiter. Każdy, kto ma ten URL, pobierze plik prosto z CDN. Mitygacja: trzymaj krótką ważność (30–60 sekund). W większości API rate limiting dotyczy uczciwego korzystania, a nie ścisłego rozliczania, więc krótkie okno obejścia jest akceptowalne.

Wsparcie redirectów po stronie klienta. Klienci twojego API muszą podążać za przekierowaniami HTTP. Wszystkie nowoczesne biblioteki HTTP robią to domyślnie, ale gołe curl wymaga flagi -L. Przed przełączeniem sprawdź w logach dostępowych user agenty — u nas 100% ruchu szło przez wysokopoziomowe biblioteki HTTP (Guzzle, Symfony HttpClient, axios, requests), które podążają za redirectami automatycznie.

Przepływ z dwoma requestami. Przy trafieniu w cache klient wykonuje dwa requesty: jeden do twojego API (dostaje 307), drugi do CDN (dostaje plik). To dokłada jeden round-trip. W praktyce i tak jest szybsze niż proxowanie, bo oba requesty są lekkie — odpowiedź API jest malutka (sam nagłówek redirectu), a CDN leży geograficznie bliżej klienta.

Konfiguracja CDN. Musisz włączyć autoryzację tokenową na swoim CDN, zarządzać kluczami bezpieczeństwa i zadbać o poprawne nagłówki CORS, jeśli z API korzystają klienci przeglądarkowi.

Kiedy stosować ten wzorzec

Ten wzorzec błyszczy, gdy:

  • Odpowiedzi są duże (ponad 10 KB) — oszczędność transferu jest proporcjonalna do rozmiaru odpowiedzi
  • Odpowiedzi są cache'owalne — ta sama treść trafia do wielu klientów
  • Jesteś na serverless — czas compute kosztuje wprost
  • Masz bugi ze streamowaniem — kodowanie treści i obsługa strumieni są podatne na błędy
  • Skalujesz się — CDN radzi sobie z globalną dystrybucją lepiej niż twój origin

Mniej się przydaje, gdy:

  • Odpowiedzi są malutkie (poniżej 1 KB) — narzut redirectu przewyższa koszt proxowania
  • Każda odpowiedź jest unikalna — brak szans na cache, zawsze zimny miss
  • Klienci nie podążają za redirectami — urządzenia embedded, bardzo stare stacki HTTP
  • Potrzebujesz transformować odpowiedź — jeśli serwer modyfikuje odpowiedź per klient, musi widzieć bajty

Najważniejsze wnioski

  1. Wzorzec z proxy jest antywzorcem przy skali. Jeśli twój serwer pobiera dane ze storage'u i przekazuje bajty, płacisz za transfer i compute, żeby robić to, co CDN robi lepiej.

  2. Podpisane URL-e dają bezpieczeństwo bez udziału serwera. CDN weryfikuje dostęp kryptograficznie — bez odpytywania twojego origin.

  3. 307 to właściwy kod redirectu dla API. Zachowuje metodę HTTP i jest powszechnie wspierany przez nowoczesne biblioteki HTTP.

  4. Sprawdzenia HEAD zapobiegają przekierowaniom w pustkę. Równoległy request HEAD do CDN potwierdza istnienie pliku przed redirectem, bez dokładania latencji.

  5. Blokady rebuildu zapobiegają thundering herd. Zwykły klucz z TTL w store'ze cache'a blokuje równoległą regenerację tego samego pliku.

  6. To wzorzec od dawna ugruntowany. Netflix, Spotify, Docker, GitHub i AWS używają jego wariantów. Nie wymyślasz niczego nowego — adoptujesz sprawdzoną architekturę.

Wdrożenie przeniosło nas z debugowania uciętych strumieni i niezgodności kodowania treści do systemu, w którym serwer origin przy trafieniu w cache nie robi prawie nic. CDN zajmuje się kompresją, globalną dystrybucją i dostarczaniem treści. Nasz serwer mówi tylko „idź tam” i bierze się za kolejny request.

Czasem najlepszą optymalizacją nie jest szybsze proxowanie bajtów — tylko nieproxowanie ich w ogóle.