fix(liste): üretim sonrası sunucu yanıt vermezse 502 sayfasına gidilmiyor

iPad raporu: liste üretildikten sonra ekran "Bad Gateway" gösterdi ve
tarayıcı `listem.txt` indirdi. Sıra şu: liste DB'ye yazıldı → `router.replace`
→ Next'in RSC isteği ters vekilden 502 aldı → Next tam-sayfa gezinmeye düştü
(WebKit düz metin cevabı dosya olarak kaydetti) → 6 sn'lik emniyet supabı
`window.location.replace` ile aynı 502'yi ekrana getirdi.

Üç değişiklik:

- Dockerfile runner katmanına `--max-old-space-size=768`. V8 cgroup sınırını
  görmez, heap üst sınırını host RAM'ine göre seçer; `mem_limit: 1g` ile
  birleşince V8 daha GC yapmadan konteyner OOM ile ölüyor ve açık istek 502'ye
  düşüyor. Sınır builder katmanında vardı, runner'a miras kalmıyordu.
- `/api/saglik`: DB/oturum/AI'ya dokunmayan en ucuz uç. Dockerfile HEALTHCHECK
  bunu yokluyor — süreç ölüp açılırken `docker ps` "unhealthy" gösterir, bir
  sonraki 502'nin kaynağı tahmine kalmaz.
- Emniyet supabı tam-sayfa gezinmeden önce sunucuyu yokluyor. Sunucu yoksa
  ekranda kalınıp "listen oluşturuldu ve kaydedildi, kredin yeniden harcanmaz"
  ekranı + sunucuyu yeniden yoklayan buton gösteriliyor; `liste_uretim_hata
  {kod:"SUNUCUYA_ULASILAMIYOR"}` olayı düşüyor. Sunucu yokken `router.refresh`
  de aynı tuzağa düştüğü için o dalda atlanıyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-22 01:08:18 +03:00
parent cc46244206
commit d41120c3ce
3 changed files with 128 additions and 3 deletions

View File

@@ -48,6 +48,15 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# V8, cgroup bellek sınırını GÖRMEZ: heap üst sınırını konteynerin değil host'un
# RAM'ine göre seçer (host 4 GB ise ~2 GB). docker-compose'daki mem_limit=1g ile
# birleşince sonuç, V8 daha GC yapmadan kernel'ın konteyneri OOM ile öldürmesi
# olur; o anda açık olan istek ters vekilde 502 "Bad Gateway" olarak düşer
# (22 Eyl 2026: liste üretiminin hemen ardından alınan 502 raporu). Sınır elle
# verilir: mem_limit'in ~%75'i; kalanı native taraf (better-sqlite3, mmap'lı
# SQLite sayfaları, Node'un kendi RSS'i) için ayrılır. mem_limit değişirse bu
# sayı da değişmeli.
ENV NODE_OPTIONS="--max-old-space-size=768"
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
@@ -81,4 +90,11 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
# Konteyner gerçekten istek karşılıyor mu (alpine'de curl yok, Node 20'nin
# global fetch'i kullanılır). Uç bilerek en ucuz olanı: app/api/saglik DB'ye,
# oturuma, AI'ya dokunmaz. Süreç OOM ile ölüp yeniden açılırken `docker ps`
# durumu "unhealthy" gösterir — 502'lerin kaynağı tahmine kalmaz.
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/saglik').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["/app/docker-entrypoint.sh"] CMD ["/app/docker-entrypoint.sh"]

View File

@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
// Sunucu ayakta mı? Bilerek en ucuz uç: DB'ye, oturuma, AI'ya dokunmaz, gövdesi
// sabittir. İki yerden sorulur:
// 1) Docker HEALTHCHECK (bkz. Dockerfile): konteyner OOM ile öldüğünde ya da
// açılışta henüz hazır olmadığında "unhealthy" görünür.
// 2) /listem üretim ekranı (bkz. features/rapor/components/liste-uretici.tsx):
// liste kurulduktan sonra yönlendirme ekranı çevirmediyse, ters vekilin
// (Traefik) düz metin 502 sayfasına tam-sayfa gezinmeden önce sunucunun
// yaşadığı buradan sorulur.
// `dynamic` segment ayarı Cache Components açıkken kaldırıldı (Next 16), o
// yüzden yalnızca no-store başlığı verilir.
export function GET() {
return NextResponse.json(
{ ok: true },
{ headers: { "Cache-Control": "no-store" } },
);
}

View File

@@ -44,12 +44,32 @@ const GECIS_SINIRI_MS = 6000;
// kesilmez, cevap gelirse akış normal devam eder. // kesilmez, cevap gelirse akış normal devam eder.
const UZUN_SURE_MS = 90_000; const UZUN_SURE_MS = 90_000;
/** Sunucu ayakta mı (bkz. app/api/saglik). Ağ hatası ve 5xx "hayır" sayılır.
* AbortSignal.timeout Safari 16'da geldi; eski iPad'lerde yokluğu yoklamayı
* "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir. */
async function sunucuAyaktaMi(): Promise<boolean> {
try {
const yanit = await fetch("/api/saglik", {
cache: "no-store",
signal:
typeof AbortSignal.timeout === "function"
? AbortSignal.timeout(5000)
: undefined,
});
return yanit.ok;
} catch {
return false;
}
}
type Durum = type Durum =
| { ad: "uretiliyor" } | { ad: "uretiliyor" }
// İstek hâlâ yolda ama beklenenden uzun sürdü: iskelet + bilgi notu // İstek hâlâ yolda ama beklenenden uzun sürdü: iskelet + bilgi notu
| { ad: "uzun-surdu" } | { ad: "uzun-surdu" }
// Liste kuruldu; yönlendiricinin /listem'e geçmesi bekleniyor // Liste kuruldu; yönlendiricinin /listem'e geçmesi bekleniyor
| { ad: "geciliyor" } | { ad: "geciliyor" }
// Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502)
| { ad: "sunucu-yok" }
| { ad: "kredi-yetersiz" } | { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string } | { ad: "hata"; mesaj: string }
| { ad: "profil-yok" }; | { ad: "profil-yok" };
@@ -75,6 +95,8 @@ export function ListeUretici({
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts). // ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
const requestIdRef = useRef<string | null>(null); const requestIdRef = useRef<string | null>(null);
const basladi = useRef(false); const basladi = useRef(false);
// "geciliyor" hâlinden sunucu-yok ekranına düşüldü mü (bkz. emniyet supabı)
const gecisBasarisiz = useRef(false);
async function uret(profil: TercihProfili) { async function uret(profil: TercihProfili) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
@@ -133,16 +155,36 @@ export function ListeUretici({
// ?uret=1'e dönüp üretimi yeniden başlatmasın. Ayrı olay adı OlayAdi // ?uret=1'e dönüp üretimi yeniden başlatmasın. Ayrı olay adı OlayAdi
// birliğini (lib/analitik.ts) genişletmeyi gerektirdiğinden supap, // birliğini (lib/analitik.ts) genişletmeyi gerektirdiğinden supap,
// liste_uretim_hata'nın `kod: "GECIS_ZORLANDI"` değeriyle ayırt edilir. // liste_uretim_hata'nın `kod: "GECIS_ZORLANDI"` değeriyle ayırt edilir.
//
// Gezinmeden ÖNCE sunucuya sorulur (22 Eyl 2026, iPad + Chrome raporu):
// konteyner üretimin hemen ardından ölürse (ör. bellek sınırı) hem Next'in
// RSC isteği hem bizim tam-sayfa gezinmemiz ters vekilin düz metin "Bad
// Gateway" cevabına düşüyor — WebKit bunu sayfa yerine dosya olarak indirip
// kullanıcıya listem.txt veriyordu. Liste o anda DB'ye YAZILMIŞ durumda;
// doğru davranış ekranda kalıp bunu dürüstçe söylemek.
useEffect(() => { useEffect(() => {
if (durum.ad !== "geciliyor") return; if (durum.ad !== "geciliyor") return;
const zamanlayici = setTimeout(() => { let iptal = false;
const zamanlayici = setTimeout(async () => {
if (!(await sunucuAyaktaMi())) {
if (iptal) return;
olay("liste_uretim_hata", { kod: "SUNUCUYA_ULASILAMIYOR" });
// Cleanup'taki router.refresh'i de kapatır: sunucu yokken refresh
// Next'i tam-sayfa gezinmeye düşürür, tam kaçındığımız yol.
gecisBasarisiz.current = true;
setDurum({ ad: "sunucu-yok" });
return;
}
if (iptal) return;
olay("liste_uretim_hata", { kod: "GECIS_ZORLANDI" }); olay("liste_uretim_hata", { kod: "GECIS_ZORLANDI" });
window.location.replace("/listem"); window.location.replace("/listem");
}, GECIS_SINIRI_MS); }, GECIS_SINIRI_MS);
return () => { return () => {
iptal = true;
clearTimeout(zamanlayici); clearTimeout(zamanlayici);
// "geciliyor" son durumdur; bu temizlik yalnızca bileşen ekrandan if (gecisBasarisiz.current) return;
// kalkarken, yani gezinme işlendikten sonra koşar — kuyruk boşken // Buraya yalnızca bileşen ekrandan kalkarken, yani gezinme işlendikten
// sonra gelinir (sunucu-yok dalı yukarıda dönüyor) — kuyruk boşken
// gönderilen refresh güvenle çalışır ve layout'taki kredi pill'ini // gönderilen refresh güvenle çalışır ve layout'taki kredi pill'ini
// (gezinme layout verisini yenilemez) günceller. // (gezinme layout verisini yenilemez) günceller.
router.refresh(); router.refresh();
@@ -205,6 +247,8 @@ export function ListeUretici({
); );
} }
if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />;
if (durum.ad === "hata") { if (durum.ad === "hata") {
return ( return (
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12"> <section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
@@ -236,6 +280,53 @@ export function ListeUretici({
return <UretimGovdesi uzunSurdu={durum.ad === "uzun-surdu"} />; return <UretimGovdesi uzunSurdu={durum.ad === "uzun-surdu"} />;
} }
/** Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor. Tam-sayfa gezinme
* burada kullanıcı isteğiyle, ancak sunucunun yaşadığı doğrulandıktan sonra
* yapılır: aksi hâlde ters vekilin düz metin 502 cevabı ekrana (iOS'ta indirme
* klasörüne) düşüyor. /listem'e ?uret=1 olmadan gitmek üretimi yeniden
* başlatmaz; kredi ikinci kez yanmaz. */
function SunucuYokEkrani() {
const [deniyor, setDeniyor] = useState(false);
const [halaYok, setHalaYok] = useState(false);
async function dene() {
setDeniyor(true);
setHalaYok(false);
if (await sunucuAyaktaMi()) {
window.location.replace("/listem");
return;
}
setDeniyor(false);
setHalaYok(true);
}
return (
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h1 className="font-heading text-xl font-bold">
Listen hazır, sunucuya şu an ulaşamıyoruz
</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Listen oluşturuldu ve hesabına kaydedildi — kredin yeniden harcanmaz.
Sunucu bir an yanıt vermedi; birkaç saniye sonra listeni açabilirsin.
</p>
<Button
size="lg"
onClick={() => void dene()}
disabled={deniyor}
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{deniyor ? "Kontrol ediliyor" : "Listemi aç"}
<ArrowRight className="size-4" aria-hidden />
</Button>
<p aria-live="polite" className="mt-4 text-xs text-slate-500">
{halaYok
? "Sunucu hâlâ yanıt vermiyor. Birkaç saniye sonra tekrar dene; listen kayıtlı, kaybolmaz."
: "Bu sayfayı kapatsan da listen hesabında durur."}
</p>
</section>
);
}
function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) { function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
const mesaj = useUretimMesaji(); const mesaj = useUretimMesaji();