Blog'a Dön
TypeScriptSecurityDesign PatternsReact NativeAuthentication

Devre Kesici (Circuit Breaker) Kalıbıyla Özel Bir Token Yöneticisi İnşa Etmek

Umut Korkmaz2025-06-0511 min read

Kimlik doğrulama token yönetimi, öyle olmayana kadar kulağa basit gelir. Token süresi dolmadan yenile, başarısız olunca giriş ekranına yönlendir — ne ters gidebilir ki? React Native bankacılık uygulamamızda yanıt şuydu: her şey. Eşzamanlı yenilemeler sırasında yarış durumları (race condition), kimlik doğrulama sunucusu çöktüğünde sonsuz yeniden deneme döngüleri, WebView bağlamlarında bayatlamış token'lar ve saatler önce sona ermesi gereken zombi oturumlar. İşte bunların tamamını çözen özel bir TokenManager'ı nasıl inşa ettiğimi anlatıyorum.

Standart Çözümleri Bozan Gereksinimler

Token yaşam döngümüzün, hazır çözümlerin üstesinden gelemeyeceği kısıtları vardı:

  1. %90 yaşam süresi yenilemesi: Token'ları süre dolumunda değil, yaşam sürelerinin %90'ında yenile
  2. Devre kesici: Sunucu çöktüğünde kimlik doğrulama sunucusunu boğmayı bırak
  3. Mutex'li çıkış (logout): Eşzamanlı tetikleyiciler olsa bile aynı anda yalnızca bir çıkış işlemi
  4. 8 saatlik oturum zaman aşımı: Token geçerliliğinden bağımsız sabit bir üst sınır
  5. 15 dakikalık WebView lütuf süresi: WebView bağlamlarına zorunlu yeniden kimlik doğrulamadan önce ekstra süre

Bulduğum hiçbir kütüphane bu beşinin tamamını karşılamıyordu. Ben de bir tane inşa ettim.

TokenManager Mimarisi

typescript
// tokenManager.ts
interface TokenState {
  accessToken: string | null;
  refreshToken: string | null;
  expiresAt: number;
  sessionStartedAt: number;
  lastActivity: number;
}

interface CircuitBreakerState {
  failures: number;
  lastFailure: number;
  state: 'closed' | 'open' | 'half-open';
}

const SESSION_TIMEOUT = 8 * 60 * 60 * 1000;     // 8 hours
const WEBVIEW_GRACE = 15 * 60 * 1000;            // 15 minutes
const REFRESH_THRESHOLD = 0.9;                    // 90% of lifetime
const CIRCUIT_BREAKER_THRESHOLD = 3;
const CIRCUIT_BREAKER_RESET = 30 * 1000;          // 30 seconds

class TokenManager {
  private state: TokenState;
  private circuitBreaker: CircuitBreakerState;
  private refreshPromise: Promise<string> | null = null;
  private logoutMutex: boolean = false;

  constructor() {
    this.state = {
      accessToken: null,
      refreshToken: null,
      expiresAt: 0,
      sessionStartedAt: 0,
      lastActivity: 0,
    };
    this.circuitBreaker = {
      failures: 0,
      lastFailure: 0,
      state: 'closed',
    };
  }
}

%90 Yaşam Süresi Yenileme Stratejisi

Çoğu uygulama, token'ları 401 yanıtı aldıklarında yeniler. Bu tepkiseldir (reactive) — ve bir bankacılık uygulamasında, token yenilenirken kullanıcının bir yükleme durumu görmesi anlamına gelir. Ben proaktif olmak istedim.

typescript
class TokenManager {
  // ...

  shouldRefresh(): boolean {
    if (!this.state.accessToken || !this.state.expiresAt) return false;

    const now = Date.now();
    const tokenLifetime = this.state.expiresAt - this.state.sessionStartedAt;
    const elapsed = now - this.state.sessionStartedAt;
    const lifetimeRatio = elapsed / tokenLifetime;

    return lifetimeRatio >= REFRESH_THRESHOLD;
  }

  async getValidToken(context: 'native' | 'webview' = 'native'): Promise<string> {
    // Check session timeout first
    if (this.isSessionExpired(context)) {
      await this.handleSessionExpiry();
      throw new SessionExpiredError();
    }

    // Proactive refresh at 90% lifetime
    if (this.shouldRefresh()) {
      return this.refreshWithDedup();
    }

    if (!this.state.accessToken) {
      throw new NoTokenError();
    }

    this.state.lastActivity = Date.now();
    return this.state.accessToken;
  }

  private isSessionExpired(context: 'native' | 'webview'): boolean {
    const now = Date.now();
    const sessionAge = now - this.state.sessionStartedAt;
    const timeout = context === 'webview'
      ? SESSION_TIMEOUT + WEBVIEW_GRACE
      : SESSION_TIMEOUT;

    return sessionAge > timeout;
  }
}

%90 eşiği bize rahat bir tampon sağlar. 1 saatlik yaşam süresine sahip bir token için, 54. dakikada yenilemeye başlarız. Kullanıcı normal kullanım sırasında token kaynaklı bir yükleme durumunu asla görmez.

Devre Kesici: Kanamayı Durdur

Kimlik doğrulama sunucusu çöktüğünde, naif yeniden deneme mantığı bir gürleyen sürü (thundering herd) sorunu yaratır. Her başarısız yenileme, başarısız olan başka bir denemeyi tetikler ve o da bir başkasını tetikler. Arka planda yenileme yapan bir mobil uygulamada bu, pili tüketir ve sunucu toparlanır toparlanmaz onu istek seline boğar.

typescript
class TokenManager {
  // ...

  private canAttemptRefresh(): boolean {
    const { state, failures, lastFailure } = this.circuitBreaker;

    switch (state) {
      case 'closed':
        return true;

      case 'open': {
        const timeSinceLastFailure = Date.now() - lastFailure;
        if (timeSinceLastFailure > CIRCUIT_BREAKER_RESET) {
          // Transition to half-open: allow one attempt
          this.circuitBreaker.state = 'half-open';
          return true;
        }
        return false;
      }

      case 'half-open':
        // Only one request allowed in half-open state
        // The refreshWithDedup method ensures this via the mutex
        return true;

      default:
        return false;
    }
  }

  private recordSuccess(): void {
    this.circuitBreaker = {
      failures: 0,
      lastFailure: 0,
      state: 'closed',
    };
  }

  private recordFailure(): void {
    this.circuitBreaker.failures += 1;
    this.circuitBreaker.lastFailure = Date.now();

    if (this.circuitBreaker.failures >= CIRCUIT_BREAKER_THRESHOLD) {
      this.circuitBreaker.state = 'open';
    }
  }
}

Durumlar, gerçek bir elektrik devre kesicisi gibi çalışır. Kapalı (closed), normal çalışma anlamına gelir — istekler geçer. Üst üste üç başarısızlıktan sonra kesici açılır (open) ve 30 saniye boyunca tüm yenileme denemelerini engeller. Bekleme süresinden sonra yarı açık (half-open) duruma geçer ve tek bir test isteğine izin verir. Bu başarılı olursa kesici kapanır. Başarısız olursa tekrar açılır.

Promise Önbellekleme ile İstek Tekilleştirme (Deduplication)

Yaşadığımız en sinsi hata, eşzamanlı token yenilemeleriydi. Kullanıcı uygulamayı açar, beş API çağrısı aynı anda tetiklenir, hepsi süresi dolmuş bir token görür ve hepsi yenilemeye çalışır. Beş yenileme isteği sunucuya çarpar; ilki token çiftini zaten döndürdüğü (rotate ettiği) için dördü geçersiz yenileme token'ı alır.

typescript
class TokenManager {
  // ...

  private async refreshWithDedup(): Promise<string> {
    // If a refresh is already in flight, reuse the same promise
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    if (!this.canAttemptRefresh()) {
      throw new CircuitBreakerOpenError(
        'Token refresh circuit breaker is open. Try again later.'
      );
    }

    this.refreshPromise = this.executeRefresh();

    try {
      const token = await this.refreshPromise;
      return token;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async executeRefresh(): Promise<string> {
    try {
      const response = await authApi.refreshToken({
        refreshToken: this.state.refreshToken,
      });

      this.state.accessToken = response.accessToken;
      this.state.refreshToken = response.refreshToken;
      this.state.expiresAt = Date.now() + response.expiresIn * 1000;
      this.state.lastActivity = Date.now();

      this.recordSuccess();
      return response.accessToken;
    } catch (error) {
      this.recordFailure();

      if (isRefreshTokenExpired(error)) {
        await this.handleSessionExpiry();
      }

      throw error;
    }
  }
}

Anahtar nokta this.refreshPromise'dir. Uçuştaki (in-flight) promise'i önbelleğe alarak, tüm eşzamanlı çağıranlar aynı işlemi bekler (await). Bu, bir mutex/semafor yaklaşımı kullanmaktan daha basit ve daha güvenilirdir; çünkü JavaScript'in tek iş parçacıklı (single-threaded) doğası, if (this.refreshPromise) kontrolünün atomik olmasını garanti eder.

Mutex'li Çıkış: Hepsini Yöneten Tek Çıkış

Çıkış (logout) ile benzer bir yarış durumu vardır. Token yenilemesi başarısız olur, devre kesici çıkışı tetikler, WebView oturum süresinin dolduğunu algılar ve çıkışı tetikler, kullanıcı çıkış düğmesine dokunur — üç eşzamanlı çıkış işlemi durumu bozabilir.

typescript
class TokenManager {
  // ...

  async logout(): Promise<void> {
    if (this.logoutMutex) {
      // Another logout is already in progress, wait for it
      return new Promise((resolve) => {
        const check = setInterval(() => {
          if (!this.logoutMutex) {
            clearInterval(check);
            resolve();
          }
        }, 100);
      });
    }

    this.logoutMutex = true;

    try {
      // Revoke tokens on the server
      if (this.state.refreshToken) {
        await authApi.revokeToken(this.state.refreshToken).catch(() => {
          // Best effort — don't block logout if revocation fails
        });
      }

      // Clear all state
      this.state = {
        accessToken: null,
        refreshToken: null,
        expiresAt: 0,
        sessionStartedAt: 0,
        lastActivity: 0,
      };

      // Reset circuit breaker
      this.circuitBreaker = {
        failures: 0,
        lastFailure: 0,
        state: 'closed',
      };

      // Notify stores
      useAuthStore.getState().clearAuth();

      // Navigate to login
      navigationRef.current?.reset({
        index: 0,
        routes: [{ name: 'Login' }],
      });
    } finally {
      this.logoutMutex = false;
    }
  }
}

WebView Lütuf Süresi

Uygulamamız, React web içeriğini WebView aracılığıyla gömüyor. Native uygulamanın oturumu sona erdiğinde WebView'i hemen sonlandıramayız — kullanıcı bir formun ortasında olabilir. 15 dakikalık lütuf süresi bunu halleder:

typescript
// In the WebView bridge
webViewBridge.onTokenRequest(async () => {
  try {
    // 'webview' context gets SESSION_TIMEOUT + WEBVIEW_GRACE
    const token = await tokenManager.getValidToken('webview');
    return { token, expiresIn: tokenManager.getRemainingTime() };
  } catch (error) {
    if (error instanceof SessionExpiredError) {
      webViewBridge.postMessage({
        type: 'SESSION_EXPIRED',
        gracePeriodRemaining: 0,
      });
    }
    throw error;
  }
});

Axios Interceptor'larıyla Entegrasyon

TokenManager, API katmanımıza Axios interceptor'ları aracılığıyla bağlanır:

typescript
apiClient.interceptors.request.use(async (config) => {
  const token = await tokenManager.getValidToken();
  config.headers.Authorization = \`Bearer \${token}\`;
  return config;
});

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401 && !error.config._retry) {
      error.config._retry = true;
      const token = await tokenManager.getValidToken();
      error.config.headers.Authorization = \`Bearer \${token}\`;
      return apiClient(error.config);
    }
    return Promise.reject(error);
  }
);

Üretimdeki Sonuçlar

TokenManager'ı yayına aldıktan sonra, eşzamanlı yenileme yarış durumlarını sıfıra indirdik (günde ~50'den), devre kesici sayesinde olaylar (incident) sırasında kimlik doğrulama sunucusu yükü %40 düştü, oturumla ilgili destek talepleri %80 azaldı ve lütuf süresi sayesinde WebView'de form terk etme oranı %35 azaldı.

Burada özel bir çözüm geliştirmek doğru karardı. Karmaşıklık, kısıtlarla haklı çıktı ve sonuç, kenar durumları (edge case) sessizce başarısız olmak yerine zarifçe ele alan bir sistem oldu. Bazen en iyi kütüphane, kendi yazdığınızdır.