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:
16
Dockerfile
16
Dockerfile
@@ -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"]
|
||||||
|
|||||||
18
src/app/api/saglik/route.ts
Normal file
18
src/app/api/saglik/route.ts
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user