REST API’lerde Yaygın Response Error Tasarımı ve Gerçek Dünya Pratikleri

Merhaba Arkadaşlar,

Uzun zamandır yazmak istediğim bir konuya geldik sonunda. Farklı şirketlerde, farklı ekiplerle çalıştım. Kendi yazdığım API’ler, başkalarının yazdığı servisler, kullandığım third-party entegrasyonlar… Hepsinde şunu fark ettim: API tasarımı genelde iyi yapılıyor. Endpoint isimleri temiz, HTTP metodları doğru, resource yapıları düzgün :p (dermişim :D). Ama konu hata yönetimine gelince işler bir anda çok basitleşiyor.

Ve bu sektörde yaygın bir sorun. Kimse çok dikkat etmiyor.

Çoğu projede şu response’u görüyorsunuz:

{
  "message": "Address cannot be resolved"
}

İlk bakışta sorun yok gibi görünüyor. Mesajı verdik, HTTP status’ü doğru koyduk, tamam. Ama bu model production’da çok hızlı kırılıyor. Bu yazıda HTTP status kodlarının neden çoğu zaman yeterli olmadığını, Problem Details standardını ve production sistemlerde kullanılan error code yaklaşımını ele alıyorum.


HTTP Status Tek Başına Yeterli Değil

HTTP status kodları aslında hatanın sadece kategorisini söyler. 400 client’ın hatalı istek gönderdiğini söyler, 404 resource’un bulunamadığını, 409 bir conflict olduğunu. Bu kadar.

Ama gerçek hayatta aynı status altında onlarca farklı hata olabiliyor. Örneğin bir 400 Bad Request alındığında bunun sebebi ne olabilir? Adres çözümlenememiş olabilir, parametre eksik olabilir, posta kodu hatalı olabilir ya da bir business rule ihlali olabilir. Client için bunların hepsi aynı şey değildir. Dolayısıyla çoğu durumda HTTP status tek başına yeterli olmuyor.


“Message” Contract Değildir

Peki status tek başına yetmiyorsa mesaj alanı işe yaramaz mı? Yarar, ama insan için. Makine için değil.

Mesaj değişebilir. Bugün “Address cannot be resolved” olan şey yarın “Invalid address” ya da “Address could not be resolved from postal code” olabilir. Bu yüzden mesajı parse eden bir client er ya da geç kırılır.

Bir de i18n senaryosu var. API Accept-Language header’ını destekliyorsa zaten mesaj doğası gereği değişiyor:

// Accept-Language: en
{ "message": "Address cannot be resolved" }

// Accept-Language: tr
{ "message": "Adres çözümlenemedi" }

// Accept-Language: de
{ "message": "Adresse konnte nicht aufgelöst werden" }

Yani mesaj zaten değişken bir alan. Üzerine business logic kurmak doğru değil. Bu yüzden production API’lerde şu ayrım yapılır: message insan için, code makine için.


Problem Details (RFC 9457)

API hatalarını standart bir formatta döndürmek için IETF tarafından tanımlanan bir standart var: Problem Details for HTTP APIs. Örnek bir response şöyle görünüyor:

{
  "type": "https://api.example.com/problems/address-not-resolved",
  "title": "Address cannot be resolved",
  "status": 400,
  "detail": "Address could not be resolved from postal code"
}

Burada özellikle type alanı önemli. Bu alan hatanın kimliğini temsil ediyor. Sabit, değişmeyen, sözleşmenin bir parçası olan bir değer. Gerisi değişebilir, bu değişmemeli.

Pratikte çoğu API bu modeli biraz genişletip bir code alanı da ekliyor:

{
  "type": "https://api.example.com/problems/address-not-resolved",
  "title": "Address cannot be resolved",
  "status": 400,
  "code": "ADDRESS_NOT_RESOLVED",
  "detail": "Address could not be resolved from postal code"
}

Frontend tarafında kullanım genelde şöyle oluyor:

if (error.code === "ADDRESS_NOT_RESOLVED") {
    showAddressCorrectionForm();
}

Mesaj parse edilmiyor. Karar error code üzerinden veriliyor.


Frontend İçin Neden Önemli?

Frontend çoğu zaman sadece hata göstermekle kalmaz, hata durumuna göre davranış üretmek zorundadır. Birkaç senaryo düşünelim:

VALIDATION_ERROR geldiğinde frontend hatalı alanları highlight eder ve formu tekrar gösterir. ADDRESS_NOT_RESOLVED geldiğinde kullanıcıya adres düzeltme ekranı açar. AUTH_TOKEN_EXPIRED geldiğinde refresh token flow’u başlatır.

Yani frontend mesajı değil, error code’u kullanır. Bu deterministik bir davranış. Mesaj yarın değişse bile uygulama aynı şekilde çalışmaya devam eder.


Gerçek Bir Production Senaryosu

Bir lojistik sisteminde pickup servisi olduğunu düşünelim. Client isteği API Gateway’e gönderir, oradan Pickup Service’e, oradan da external provider’a gider. Provider bazen timeout döndürür.

Pickup servisi bu hatayı şöyle üretirse:

{
  "type": "/problems/provider-timeout",
  "title": "Provider timeout",
  "status": 504,
  "code": "PROVIDER_TIMEOUT",
  "retryable": true
}

Gateway bu response’u görünce retryable: true olduğu için otomatik retry yapabilir. Frontend ise kullanıcıya “Tekrar deneyin” önerisinde bulunabilir. Tüm bu kararlar mesaj üzerinden değil, code ve metadata üzerinden alınıyor. İşte gerçek hayatta farkı bu şekilde hissediyorsunuz.


Symfony’de Uygulama

Symfony’de genelde exception -> API error mapping yapılır. Önce domain exception’ı tanımlıyoruz:

final class AddressNotResolvedException extends \RuntimeException
{
}

Daha sonra bir exception listener yazıyoruz:

final class ApiExceptionListener
{
    public function onKernelException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();

        if ($exception instanceof AddressNotResolvedException) {
            $event->setResponse(new JsonResponse([
                'type'   => '/problems/address-not-resolved',
                'title'  => 'Address cannot be resolved',
                'status' => 400,
                'code'   => 'ADDRESS_NOT_RESOLVED',
                'detail' => $exception->getMessage(),
            ], 400));
        }
    }
}

Evet gördüğünüz gibi domain exception’ımız API contract’a dönüşüyor. Bu ayrım önemli çünkü domain tarafında AddressNotResolvedException fırlatırsınız, API tarafında client ADDRESS_NOT_RESOLVED görür. Implementation detayı dışarıya sızmaz.


Error Catalog

Error code kullanmaya başladığınızda kısa süre sonra başka bir sorun ortaya çıkıyor: her ekip kendi hata kodlarını üretmeye başlıyor. Bir süre sonra şunları görüyorsunuz:

INVALID_ADDRESS
ADDRESS_INVALID
ADDRESS_NOT_VALID
POSTAL_CODE_INVALID

Hepsi aynı problemi anlatıyor. Bu karmaşayı önlemek için ekipler Error Catalog yaklaşımını kullanır. Merkezi bir liste, herkesin uyduğu bir standart:

CodeHTTP StatusRetryable
ADDRESS_NOT_RESOLVED400false
PICKUP_ALREADY_EXISTS409false
PROVIDER_TIMEOUT504true
VALIDATION_ERROR422false

Naming convention da önemli. Genelde DOMAIN_REASON pattern’i kullanılır: ADDRESS_NOT_RESOLVED, AUTH_TOKEN_EXPIRED, PROVIDER_TIMEOUT gibi. Okunabilir, tutarlı ve yönetilebilir kalıyor.


Kaçınılması Gereken Anti-Pattern’ler

Son olarak production’da sık gördüğüm birkaç hataya değineyim.

Message parse etmek: error.message.includes("address") yazan her satır potansiyel bir bomba. Mesaj değişir, dil değişir, uygulama kırılır.

Her şeye 500 dönmek: Client hatanın sebebini anlayamaz. Retry mi yapmalı, kullanıcıya hata mı göstermeli, başka bir flow mu başlatmalı? Bilmiyor.

Internal exception sızdırmak: Şunu görüyorsunuz bazen:

{
  "error": "Doctrine\\DBAL\\Exception\\UniqueConstraintViolationException"
}

Implementation detayı dışarı sızıyor. Security açısından da sorunlu.

Tutarsız error formatı: /auth endpoint’i { error: "" } dönerken /orders endpoint’i { message: "" } dönüyor. Frontend için ciddi bir karmaşa yaratıyor.


Sonuç

HTTP status kodları API tasarımının önemli bir parçası ama tek başına yeterli değil. Production sistemlerde şu üçlü genelde birlikte kullanılıyor: HTTP status + Problem Details + stable error code. Bu yaklaşım frontend’e deterministik davranış sağlıyor, i18n ile uyumlu çalışıyor ve API contract’ı stabil tutuyor.

Aslında bunların hepsi şu soruya yanıt veriyor: “Bu hatayı gören developer ya da sistem ne yapacağını biliyor mu?” Eğer biliyorsa, doğru tasarlanmış demektir.

Bir sonraki makalede görüşmek üzere 🙂


Kaynaklar