İçeriğe geç
codebiy
← Yazı masasına dön

Tek düğme, yirmi OpenAI çağrısı: takılan isteğe süre sınırı

Moonhush'un canlıda yazdığı ilk masal 24 saniyede hazırdı ama yanıt 57 saniyede geldi: yirmi OpenAI çağrısından biri, tek bir seslendirme isteği takılmıştı. Kodda neyi kontrol ettiğimiz ve süre sınırları bu yazıda.

Moonhush, resimli ve sesli uyku masalları yazan iOS uygulamamız: her masalın kahramanı çocuğun kendisi. Ekim 2026'da App Store incelemesinde, henüz yayımlanmadı. Uygulamayı Expo SDK 57 ve React Native 0.86 ile geliştirdik. Bütün model çağrıları sunucuda, Supabase Edge Function'larında yapılıyor ve hepsi OpenAI API'sine gidiyor. Sekiz sayfalık bir masalda tek dokunuş bu çağrılardan yirmisini başlatıyor: biri masalı yazıyor, biri denetliyor, sekizi resimleri üretiyor, onu da seslendirmeyi kaydediyor.

7 Ekim 2026'da canlıdaki fonksiyonla yazdırdığımız ilk masal, tek bir seslendirme klibi dışında 24 saniyede hazırdı. O klibin isteği hiç bitmedi, yanıt da klibin 45 saniyelik zaman aşımı dolana kadar bekleyip 57 saniyede geldi. Her çağrının kendi başladığı andan sayılan zaman aşımlarını kaldırdık, yerlerine isteğin başından sayılan süre sınırları koyduk. Bütçemiz altmış saniyeydi: Apple'ın belgelerine göre veri gelmeyen bir isteğin varsayılan zaman aşımı bu kadar. 9 Ekim 2026'da, uygulamayla birlikte gelen ağ kodunu okuduk ve aynı biçimde kurulan bir isteğin süresini iOS simülatöründe ölçtük: o istek bu varsayılanla çalışmıyor.

Düğmenin arkasındaki akış: yazma, denetim, resim, seslendirme

Uygulamanın içinde model API anahtarı yok: fonksiyon, daha hiçbir model çalışmadan çağıranın kim olduğunu kontrol ediyor ve isteği doğruluyor. Ailenin bu gece masal alıp alamayacağına da fonksiyon karar veriyor. Kendi verisini okuyamazsa tahmin yürütmüyor, duruyor. Az önce abone olmuş olabilecek bir kullanıcıyı geri çevirmeden önce RevenueCat'e doğrudan soruyor, çünkü satın almayı bildiren webhook gecikebilir.

Masalın tamamı OpenAI'ın Chat Completions API'sine yapılan tek çağrıda, structured output ile yazılıyor. Bu modda modelin yanıtı, verilen JSON şemasına uymak zorunda. Biz strict: true ile kullanıyoruz. Yanıtta masalın başlığı, çizer için yazılmış tek ve sabit bir kahraman tarifi ve üç tipte sayfa var: okunacak metin, bulunup dokunulacak şeylerin olduğu sayma sayfası ve iki yanıtlı seçim sayfası. İki yanıtın devamı da aynı çağrıda yazılıyor, yani seçim anında modeli beklemek gerekmiyor.

Ardından daha ucuz, ikinci bir model tamamlanmış JSON'u okuyup kararını bildiriyor. Fonksiyon, masalı yalnızca karar güvenliyse geçiriyor. Karar güvensizse de hiç gelmezse de masal reddediliyor: karar yoksa masal da yok.

Resimler ve seslendirme yalnızca metne bağlı, bu yüzden masal denetimden geçer geçmez paralel üretiliyorlar: her sayfa için bir resim ve bir klip, seçim sayfasının iki yanıtı için de birer klip, yani sekiz sayfa için on klip. Üretilemeyen resmin yeri boş kalıyor ama masal bu yüzden asla başarısız sayılmıyor. Üretilemeyen klip yanıtta yer almıyor: okuma ekranı onu yeniden istiyor, sekiz saniye içinde kayıt gelmezse sayfayı cihazın kendi sesi okuyor.

Moonhush'ta “Mila ve Ayın Fısıltısı” masalının ilk sayfası: üstte oyuncak tavşanına sarılmış bir çocuğun resmi, altta bir kelimesi vurgulanmış sayfa metni. Uygulamadaki okuma sayfası: bir resim, metin ve vurgulanmış bir kelime.

Fonksiyon masalı reddettiğinde ya da yanıt vermediğinde ebeveyn, sebep ne olursa olsun aynı mesajı görüyor: “Bu gecenin yeni masalı yazılamadı. Bağlantıyı kontrol edip tekrar dene ya da hazır bir masal oku.” Uygulamada hazır masallar var, ama hiçbiri bu gecenin yeni masalıymış gibi sunulmuyor.

Structured output'tan sonra kodda kontrol ettiklerimiz

Strict bir şema, yanıtın biçimini tarif eder, anlamı hakkında bir şey söylemez. Biz şemada string alanlarına maxLength de koyuyoruz. OpenAI'ın kılavuzunda Structured Outputs'un desteklediği şema anahtar kelimeleri listeleniyor: string'ler için pattern ve format sayılıyor, maxLength listede yok. API şemamızı yine de kabul ediyor. Bu yüzden uzunluk sınırlarını belgelenmemiş davranış sayıyor ve şu alanları kodda kontrol ediyoruz:

  • Emoji yerine kelime. Sayma sayfasında dört karakterle sınırlı bir emoji alanı var. Ağustos 2026'da model oraya kelime yazdı ve kelime kesilmiş geldi: “butterfly” yerine “but”, yani dört karakterlik sınırın altında üç harf. Neden bir harf erken kesildiğini not almadık. Artık prompt'ta tek bir piktogram istiyoruz, alanda başka bir şey varsa da sayfada yıldız gösteriyoruz.
  • Sayfada geçmeyen vurgulu kelime. Her okuma sayfasında bir kelime, kısa bir açıklamayla vurgulanıyor. Fonksiyon bu kelimeyi ancak sayfa metninde, gösterildiği hâliyle geçiyorsa tutuyor.
  • Uygulamanın kabul ettiğinden uzun sayfa. Uygulama, bir sayfası 400 karakterden uzun olan masalın tamamını reddediyor. O noktada masalın parasını ödemiş oluyoruz, masal ailenin hakkından da düşülmüş oluyor. Bu kontrol, incelemedeki build'in içine derlenmiş durumda. İlk prompt sayfa başına 40–70 kelime istiyordu, bu kadarı da Türkçede ve Almancada sınıra sığmıyor. Prompt şimdi 35–55 kelime ve en fazla 380 karakter istiyor. Yine de sınıra dayanan sayfayı fonksiyon, son tam cümlesine kadar kısaltıyor.

Bu alanlardan çıkardığımız kural şu: bütçeyi prompt'ta söylemek, sonra kodda onarmak ya da reddetmek. Başka bir ürünümüz olan Spoolt'ta da aynı kurala, başka bir uzunluk sınırı yüzünden vardık: orada prompt'tan koda taşıdıklarımız.

Canlıdaki ilk denemeler: takılan tek seslendirme çağrısı

7 Ekim 2026'daki denemeleri yapan da düzeltmeleri yazan da repo'muzda çalışan bir yapay zekâ agent'ıydı. Canlıdaki fonksiyonu bir Mac'ten, uygulamanın yaptığı gibi gerçek bir oturumla çağırdı. Elinde test edecek telefon yoktu. Yazdırdığı dört deneme masalı, yazılış sırasıyla:

Deneme Masal Yanıt süresi Takılan
1 olağan uzunluk, 7 ya da 8 sayfa 57 sn tek seslendirme isteği, kendi 45 sn'lik zaman aşımına kadar
2 olağan uzunluk, 7 ya da 8 sayfa 45 sn'nin biraz üstü tek seslendirme isteği, ortak süre sınırına kadar
3 uzun, 9 sayfa 31 sn yok
4 kısa, 5 sayfa 19 sn yok

Birinci denemede her çağrının ayrı bir zaman aşımı vardı: klip için 45, resim için 60 saniye. İkisi de o çağrının başladığı andan sayılıyordu. İlk düzeltmede bütün resim ve kliplere, isteğin başından sayılan tek bir süre sınırı koyduk.

İllüstrasyon: tek düğmeli küçük konsoldan çıkan kablolar çok sayıda kum saatine gidiyor, içlerinden yalnızca biri kırmızı ve kumu bitmiş. Tek dokunuş, çok sayıda çağrı: her çağrının zaman aşımı ayrı, birininki dolmuş.

İkinci denemede de tek bir seslendirme isteği hiç bitmedi. Sebebin rate limit olmadığını görmek için aynı anda on iki ayrı seslendirme isteği gönderdik, hepsi 3–4 saniyede döndü. Yani takılma arada bir oluyordu. İkinci düzeltmeden sonra bir klip isteği 20 saniyede kesiliyor ve bir kez tekrarlanıyor. Üçüncü ve dördüncü denemelerde hiçbir istek takılmadı, bu yüzden o tekrarın canlı API'de çalıştığını henüz görmedik.

Üçüncü düzeltme, ilk ikisini yeniden okurken çıktı. Tek ortak süre sınırı resimleri kliplerle aynı anda kesiyordu. Oysa eksik klip sonradan alınabiliyor, eksik resim alınamıyor.

İsteğin başından sayılan süre sınırları

Fonksiyonda artık iki süre sınırı, bir de tek bir klip isteği için ayrı bir sınır var. startedAt, istek handler'ının ilk satırında alınıyor:

const CLIPS = { by: 45_000, min: 15_000 };
const PICTURES = { by: 50_000, min: 20_000 };
const CLIP_TRY_MS = 20_000;

const until = (limit: { by: number; min: number }) =>
  AbortSignal.timeout(Math.max(limit.min, limit.by - (Date.now() - startedAt)));

by, isteğin başından sayıldığında işin en geç ne zaman bitmesi gerektiği. min, masalın yazımı uzun sürdüğünde işe yine de kalan süre. Seslendirme daha erken kesiliyor, çünkü okuma ekranı eksik klibi sonradan alabiliyor.

Her klip, en fazla iki kez dönen bir döngüde isteniyor. Döngü aşağıda. İstek ayrıntılarını, deneme sayacını ve log satırını buraya almadık. clipsBy, until(CLIPS) değeri ve bütün klipler için ortak:

for (let attempt = 1; attempt <= 2; attempt++) {
  const tryBy = AbortSignal.timeout(CLIP_TRY_MS);
  try {
    const r = await fetch('https://api.openai.com/v1/audio/speech', {
      // … method, headers and body omitted
      signal: AbortSignal.any([clipsBy, tryBy]),
    });
    if (!r.ok) throw new Error(await r.text());
    // … the clip is stored and its address returned
  } catch (e) {
    // Only a try that ran out of its own time is made again: a refusal would be refused again.
    if (!tryBy.aborted || clipsBy.aborted) break;
  }
}

AbortSignal.any, isteği hangisi önce dolarsa onda kesiyor: o isteğin kendi 20 saniyesi ya da ortak süre sınırı.

iOS'te istek zaman aşımı: Apple'ın varsayılanı ve Expo'nun fetch'i

Süre sınırlarını, fonksiyon altmış saniye içinde yanıt versin diye koyduk. Bu rakam Apple'ın belgelerinde yazıyor: URLSession config'indeki timeoutIntervalForRequest varsayılan olarak 60 saniyedir ve sayacı her yeni veri geldiğinde baştan başlar. Bizim fonksiyonumuz masalın tamamı hazır olana kadar hiçbir şey göndermiyor.

9 Ekim 2026'da, bu yazı düzeltilirken, bir yapay zekâ agent'ı isteğin uygulamadan nasıl çıktığının izini sürdü. Uygulama, fonksiyonu supabase-js 2.112 ile çağırıyor ve zaman aşımı vermiyor. Expo SDK 57'de, iOS'te global fetch olarak expo/fetch kuruluyor. Uygulamayla birlikte gelen expo paketinde (57.0.18) bu fetch, URLSession'ı varsayılan config'le kuruyor, sonra her isteğin timeoutInterval değerini 0 yapıyor.

Agent sonra bu ikisi birlikteyken isteğin ne kadar beklediğini iOS 27 simülatöründe ölçtü. Test küçük bir Swift programıydı, uygulamayı çalıştırmadı: iki isteği Expo'nun kodundaki gibi kurdu ve bunları, isteği kabul edip hiç yanıt vermeyen yerel bir sunucuya gönderdi. timeoutInterval değeri değiştirilmeyen istek 62 saniye sonra NSURLErrorTimedOut hatasıyla bitti. timeoutInterval değeri 0 yapılan istek, test 150. saniyede durdurulduğunda hâlâ bekliyordu.

Elimizdeki kanıt, uygulamayla birlikte gelen kod ve simülatördeki bu ölçüm. Buna göre altmış saniye bizim bütçemizdi, iOS'in isteğimize uyguladığı bir sınır değil. Bildiğimiz bir sonraki sınır Supabase'in: Edge Function 150 saniye içinde yanıt göndermezse 504 dönüyor.

Süre sınırlarını yerinde bırakıyoruz, çünkü gerekçeleri telefona bağlı değil: birinci denemede 24 saniyede hazır olan masalı tek bir klip 33 saniye daha bekletti.

Seçmediğimiz yollar: istemcide zaman aşımı, streaming, hemen yanıt

7 Ekim 2026'daki süre sınırı düzeltmelerinin üçünü de fonksiyonda yaptık, hiçbirinde uygulamayı değiştirmedik. Uygulama App Store incelemesindeydi ve o çalışma turuna başlamadan önce yazdığımız kurallardan biri, çıkmış build'lerin bozulmamasıydı: fonksiyonun yanıtı biçimini korur, yani uygulamanın doğruladığı tek bir JSON yanıtı olarak kalır. Fonksiyondaki değişiklik bütün build'lere aynı anda ulaşır. Uygulamadaki değişiklik ise yeni bir build ya da OTA (over-the-air) güncelleme ister, OTA güncelleme de kurulu build'leri kendine özgü bir yoldan bozabiliyor.

Bu kural, uzun ve sessiz beklemeye son verecek iki değişikliği eledi: masal yazılırken ara ara veri göndermek (streaming) ve hemen yanıt verip bitmiş masalı uygulamaya sonradan aldırmak. İkisi de fonksiyonun döndürdüğü yanıtı değiştiriyor, ikisini de yapmadık. Uygulamada daha uzun bir zaman aşımı da işe yaramazdı: supabase-js'in kabul ettiği timeout, isteği yalnızca daha erken bitirebilir.

Ölçmediklerimiz

iPhone'dan yapılan bir masal isteğinin süresini ölçmedik. Sıradaki build'i, incelemeye gitmeden önce 8 Ekim 2026'da bir insan TestFlight üzerinden gerçek bir cihazda denedi ama o sırada istek süresi ölçülmedi. Dört denemenin hepsi Mac'ten yapıldı, hiçbiri de altmış saniyeye ulaşmadı.

İstediğimiz anda oluşturamadığımız durumları (bir kez ya da kalıcı olarak takılan klip, takılıp kalan resimler, yavaş yazılan masal) canlıda denemedik. Fonksiyonun kendi dosyasını, veritabanının ve OpenAI'ın sahte sürümleriyle, bütün gecikmeleri yüzde birine indirerek çalıştırdık.

Çözmediğimiz bir sınır hâlâ var: masalın yazımı yavaşsa yanıt da o kadar gecikiyor. İstek başarısız olursa uygulama, sunucunun yine de bitirdiği masalı on dakika boyunca arıyor, bulursa kitaplığa ekliyor ve ebeveyne “Bu gecenin masalı geldi” diyor.

Çok sayıda model çağrısı başlatan tek istek için kontrol listesi

  • Hata verebilecek kodu yazmadan önce, her hatada işin durup durmayacağına karar verin. Bizde gelmeyen karar masalı durduruyor, eksik resim ya da klip durdurmuyor.
  • Sağlayıcının hangi şema anahtar kelimelerini desteklediğini okuyun, sonra önemli alanları kodda kontrol edin.
  • Süre sınırlarını isteğin başından sayın, uzun olanı da sonradan alınamayacak şeye verin.
  • Zaman aşımına uğrayanı yeniden deneyin, reddedileni bırakın.
  • İsteğinizin gerçekte hangi zaman aşımıyla çalıştığını öğrenin: uygulamayla birlikte gelen ağ kodunu okuyun ya da yanıtsız kalan bir isteğin süresini ölçün.
  • Neyi nereden ölçtüğünüzü ve neyi ölçmediğinizi söyleyin.
OKUMAYA DEVAM ETYapay zekânın yazdığı front-end'i doğrulamak: build yeşil, sayfa bozuk ↗Prompt rica eder, kod garanti eder: LLM'ye bırakmadığımız üç kural ↗