- 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:
Client → API 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.
Client → API Server (auth + generate signed URL) → 307 Redirect
Client → CDN 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:
| Kod | Metoda zachowana | Zastosowanie |
|---|---|---|
| 301 | Brak gwarancji | Trwałe zmiany URL-i |
| 302 | Brak gwarancji | Legacy, niejednoznaczne zachowanie |
| 307 | Tak, ściśle | Tymczasowy redirect, metoda zachowana |
| 308 | Tak, ściśle | Trwał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:
- Gdy dane się zmieniają, ustaw w swoim store'ze cache'a znacznik czasu inwalidacji
- Kolejny request porównuje znacznik czasu pliku ze znacznikiem inwalidacji
- Jeśli plik jest nieaktualny, wpada w ścieżkę regeneracji
- Ś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):
| Zagadnienie | Podejście z proxy | Podejście z redirectem do CDN |
|---|---|---|
| Content-Type | Ustawiany ręcznie per format | Wnioskowany z rozszerzenia pliku |
| Kompresja | Musisz sam obsłużyć gzip/brotli | Automatyczna na edge, per klient |
| Content-Length | Podatny na błędy przy skompresowanych strumieniach | Zawsze poprawny |
| Dystrybucja globalna | Jeden region origin | Serwowane z najbliższego edge |
| Koszt transferu | Płacony przy każdym requeście | Zero przez origin |
| Ryzyko niepełnej odpowiedzi | Realne, trudne do wykrycia | CDN obsługuje ponowienia |
Dla plików XML i JSON CDN-y wnioskują poprawny Content-Type z rozszerzenia pliku (.json → application/json, .xml → application/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:
| Metryka | Wzorzec z proxy | Wzorzec 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 serverless | Pełny czas trwania requestu | Tylko sprawdzenie uwierzytelnienia |
| Tryby awarii | Ucię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
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.
Podpisane URL-e dają bezpieczeństwo bez udziału serwera. CDN weryfikuje dostęp kryptograficznie — bez odpytywania twojego origin.
307 to właściwy kod redirectu dla API. Zachowuje metodę HTTP i jest powszechnie wspierany przez nowoczesne biblioteki HTTP.
Sprawdzenia HEAD zapobiegają przekierowaniom w pustkę. Równoległy request HEAD do CDN potwierdza istnienie pliku przed redirectem, bez dokładania latencji.
Blokady rebuildu zapobiegają thundering herd. Zwykły klucz z TTL w store'ze cache'a blokuje równoległą regenerację tego samego pliku.
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.