OAuth2 Server Error'larını Çözmek

Selamlar, bu yazımda OAuth2 ile çalışırken hepimizin er ya da geç yüzleştiği o sinir bozucu server_error yanıtlarına bakacağız. Bilirsiniz, request gönderiyorsunuz, kod tarafında her şey kitabına uygun, ama yetkilendirme sunucusu size 500 ya da 503 fırlatıyor. Sebebini de açıklamıyor üstelik. Hadi konuya girelim.

OAuth2'de 4xx hataları size 'request'in yanlış' der; düzeltmek genelde sizin elinizdedir. 5xx ise 'sorun bende' demektir ve genellikle geçici olur. İşin sıkıntılı yanı, sunucu logunu görme şansınız olmadığında bu geçici miydi yoksa kalıcı bir arıza mı, ayırt etmek zor.

Server Error neye benziyor?

Tipik bir 5xx yanıtı şu şekilde gelir:

{
  "error": "server_error",
  "error_description": "The authorization server encountered an unexpected condition that prevented it from fulfilling the request."
}

Burada görmemiz gereken iki şey var: HTTP statüsü (500, 502, 503, 504) ve OAuth2'nin kendi error alanı. İkisi farklı şeyler söyleyebilir. Mesela 200 dönüp body'de error: server_error koyan bizim de baş belamız servisler oldu zamanında. O yüzden response.ok kontrolü tek başına yetmez; body'yi de okumak gerekir.

Hangi statü ne anlama geliyor, kabaca:

  • 500: Sunucu kendi içinde patladı. Genelde yetkilendirme servisinin loguna bakmak lazım.
  • 502: Önündeki proxy ya da load balancer arka uca ulaşamıyor. Servis ayakta olabilir, route bozuk.
  • 503: Servis aşırı yük altında ya da bakımda. Bekleyip tekrar denemek mantıklı.
  • 504: Timeout. Genelde veri tabanı sorgusu uzadığında ya da downstream bir bağımlılık takıldığında görülür.

Yeniden deneme stratejisi: üstel backoff

Geçici hata için en doğru tepki sabırla yeniden denemektir, ama körlemesine değil. Üstel backoff (exponential backoff) tam burada işe yarar. Her başarısız denemede bekleme süresini katlıyoruz; üstüne biraz da rastgelelik (jitter) ekliyoruz ki tüm istemciler aynı anda saldırmasın.

class OAuth2RetryClient {
    constructor(config) {
        this.config = config;
        this.maxRetries = config.maxRetries ?? 3;
        this.baseDelay = config.baseDelay ?? 1000;
        this.maxDelay = config.maxDelay ?? 30000;
    }

    async tokenRequest(params) {
        const response = await fetch(this.config.tokenEndpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams(params).toString(),
        });

        if (!response.ok) {
            const body = await response.json().catch(() => ({}));
            const err = new Error(body.error_description ?? 'Token isteği başarısız');
            err.status = response.status;
            err.code = body.error;
            throw err;
        }
        return response.json();
    }

    async withRetry(operation) {
        for (let attempt = 0; attempt < this.maxRetries; attempt++) {
            try {
                return await operation();
            } catch (error) {
                if (!this.isRetryable(error) || attempt === this.maxRetries - 1) {
                    throw error;
                }
                const delay = this.calculateDelay(attempt);
                await new Promise(r => setTimeout(r, delay));
            }
        }
    }

    isRetryable(error) {
        if (error.status >= 500 && error.status < 600) return true;
        if (error.code === 'temporarily_unavailable') return true;
        return false;
    }

    calculateDelay(attempt) {
        const exp = this.baseDelay * Math.pow(2, attempt);
        const jitter = exp * 0.25 * Math.random();
        return Math.min(exp + jitter, this.maxDelay);
    }
}

Bence buradaki kritik nokta isRetryable metodunun ne kadar dar tutulduğu. 4xx'i tekrar denerseniz aynı hatayı tekrar alırsınız ve kullanıcı bekler durur. Sadece 5xx ve temporarily_unavailable için tekrar deneyin.

Circuit breaker: dur dediğinde dur

Yetkilendirme sunucusu gerçekten çökmüşse her istekte üç kez tekrar denemek sistemi daha da yorar. Burada devre kesici (circuit breaker) deseni devreye girer. Kısaca: belli sayıda art arda hata olduğunda devreyi 'OPEN'a alıp bir süre hiç istek geçirmeyiz, sonra 'HALF_OPEN'da bir deneme yapıp servis düzeldiyse 'CLOSED'a döneriz.

class CircuitBreaker {
    constructor({ threshold = 5, resetMs = 30000 } = {}) {
        this.threshold = threshold;
        this.resetMs = resetMs;
        this.state = 'CLOSED';
        this.failures = 0;
        this.openedAt = null;
    }

    async execute(operation) {
        if (this.state === 'OPEN') {
            if (Date.now() - this.openedAt >= this.resetMs) {
                this.state = 'HALF_OPEN';
            } else {
                throw new Error('Devre kesici acik, istek gonderilmedi');
            }
        }
        try {
            const result = await operation();
            this.failures = 0;
            this.state = 'CLOSED';
            return result;
        } catch (e) {
            this.failures++;
            if (this.failures >= this.threshold) {
                this.state = 'OPEN';
                this.openedAt = Date.now();
            }
            throw e;
        }
    }
}

Sık karşılaşılan hatalar

  • Tüm hatalarda tekrar denemek: 400 invalid_grant hatasını 3 kez denemek refresh token'ı boşa harcar ve kullanıcıyı tekrar login'e atar. Önce statüye bakın.
  • Jitter koymamak: 1000 istemci aynı anda 503 yiyip aynı anda yeniden denerse sunucuyu tekrar dize getirirsiniz. Rastgelelik şart.
  • Kullanıcıya teknik mesaj göstermek: 'server_error' yazısı kullanıcıya bir şey ifade etmez. 'Giriş servisi şu an yoğun, az sonra tekrar deneyin' gibi sade bir mesaj çok daha iyi.
  • Retry-After başlığını okumamak: Sunucu size ne kadar bekleyeceğinizi söylüyor olabilir. Kendi backoff'unuzdan önce o değere bakın.

Kapanış

5xx hataları çoğunlukla kendiliğinden geçer; bizim işimiz panik yapmadan, akıllı bir geri çekilmeyle sistemi ayakta tutmak. Bana sorarsanız üstel backoff ve devre kesici ikilisi olmadan hiçbir OAuth2 istemcisi production'a çıkmamalı; bu ikisi olmadan ilk küçük dalgalanmada kullanıcı oturum açamıyor diye ortalık karışır. Umarım faydalı olur, bir sonraki yazıda görüşmek üzere.