Race condition pada refresh token resolver GraphQL biasanya terjadi ketika beberapa request yang datang hampir bersamaan sama-sama mendeteksi access token kedaluwarsa, lalu semuanya mencoba melakukan refresh pada waktu yang sama. Hasilnya tidak selalu gagal total, tetapi sering muncul sebagai gejala yang membingungkan: 401 acak, token terbaru tertimpa token lama, retry yang berantai, atau error yang hanya terjadi sesekali di production.

Masalah ini jarang terlihat pada pengujian manual satu request per satu waktu. Namun pada aplikasi GraphQL, satu aksi pengguna dapat memicu banyak resolver, banyak panggilan API downstream, atau beberapa tab/browser yang aktif bersamaan. Jika lapisan auth tidak memiliki deduplikasi refresh, locking per session, atau mekanisme rotasi token yang aman, bug ini sangat mudah terjadi.

Gejala Nyata di Production

Race condition pada lapisan refresh token sering tidak muncul sebagai satu stack trace yang jelas. Gejalanya tersebar di berbagai lapisan.

  • Lonjakan 401 acak meskipun pengguna baru saja login atau masih aktif.
  • Token tertimpa: request A menyimpan token hasil refresh, lalu request B yang mulai sedikit lebih lambat menyimpan token lama atau token hasil refresh lain yang sudah tidak valid.
  • Retry berantai: gateway, client, atau resolver mencoba ulang request karena 401, tetapi retry itu justru memicu refresh tambahan.
  • Error sulit direproduksi: di lokal semua baik-baik saja, tetapi di production muncul saat traffic padat atau saat satu user membuka banyak tab.
  • Invalid refresh token pada provider auth yang menerapkan refresh token rotation, karena token refresh yang sama dipakai dua kali.

Pola ini sering disalahartikan sebagai masalah cache, clock skew, atau bug provider OAuth. Padahal akar masalahnya ada pada konkurensi di aplikasi sendiri.

Kenapa Resolver GraphQL Rentan terhadap Race Condition

Pada REST, satu halaman sering identik dengan beberapa endpoint, tetapi alur auth biasanya terpusat di middleware. Pada GraphQL, kerentanan bisa lebih halus karena:

  • Satu operasi GraphQL dapat memicu beberapa resolver yang berjalan hampir bersamaan.
  • Aplikasi client sering mengirim beberapa operasi paralel setelah halaman dimuat.
  • Lapisan auth kadang diletakkan di context builder, plugin, data source, atau helper yang dipanggil berulang.
  • Jika token disimpan di cache/session bersama, beberapa eksekusi request dapat membaca status token yang sama lalu sama-sama memutuskan untuk refresh.

Masalah inti biasanya bukan GraphQL-nya, melainkan keputusan refresh yang tidak atomik.

Root Cause Teknis

1. Shared state tanpa sinkronisasi

Contoh paling umum: access token dan refresh token disimpan di cache, Redis, memory store, atau session record. Dua request membaca nilai yang sama, melihat token sudah kedaluwarsa, lalu dua-duanya melakukan refresh.

Jika penulisan hasil refresh tidak atomik, request yang selesai belakangan bisa menimpa token yang lebih baru.

2. Refresh token dipakai ganda

Banyak sistem auth mendukung refresh token rotation: setiap refresh menghasilkan refresh token baru dan token lama menjadi tidak valid. Jika dua request memakai refresh token lama yang sama:

  1. Request A refresh sukses dan menerima pasangan token baru.
  2. Request B mencoba refresh dengan token lama yang kini sudah invalid.
  3. Request B gagal 401/403, atau lebih buruk, menulis state yang tidak konsisten.

Inilah alasan bug tampak acak: hasilnya bergantung pada urutan selesai request.

3. Cache/session update tidak atomik

Masalah bukan hanya saat memanggil endpoint refresh, tetapi juga saat menyimpan hasilnya. Misalnya:

  • baca session lama
  • refresh
  • tulis semua object session kembali

Jika dua request melakukan read-modify-write pada object yang sama tanpa kontrol versi atau lock, penulisan terakhir bisa menghapus pembaruan sebelumnya.

4. Tidak ada deduplikasi request refresh

Jika lima request untuk user/session yang sama masuk bersamaan, idealnya hanya satu yang benar-benar memanggil endpoint refresh. Empat lainnya cukup menunggu hasil yang sama. Tanpa mekanisme ini, semua request akan mengeksekusi refresh sendiri-sendiri.

Alur Reproduksi Bug

Berikut skenario yang realistis dan sering terjadi:

  1. User memiliki access token yang sudah hampir atau sudah kedaluwarsa.
  2. Frontend memicu beberapa operasi GraphQL paralel, misalnya viewer, notifications, dan dashboard summary.
  3. Setiap request masuk ke auth layer dan membaca token yang sama dari session/cache.
  4. Semua request memutuskan token harus di-refresh.
  5. Request pertama berhasil refresh dan menyimpan refresh token baru.
  6. Request kedua masih memakai refresh token lama dan gagal, atau menulis ulang state lama.
  7. Sebagian resolver sukses, sebagian gagal 401, client lalu retry, beban meningkat, gejala makin acak.

Jika ada retry otomatis di client atau API gateway, efeknya bisa berlipat ganda dan tampak seperti insiden besar padahal pemicunya satu race condition kecil.

Contoh Implementasi yang Bermasalah

Berikut contoh pseudo-code Node.js yang mewakili pola bug umum. Intinya bukan pada framework tertentu, melainkan pada keputusan refresh yang dilakukan per request tanpa koordinasi.

async function getValidAccessToken(sessionId) {
  const session = await sessionStore.get(sessionId);

  if (!session) {
    throw new Error('Unauthenticated');
  }

  if (session.accessTokenExpiresAt > Date.now()) {
    return session.accessToken;
  }

  const refreshed = await authProvider.refresh({
    refreshToken: session.refreshToken,
  });

  await sessionStore.set(sessionId, {
    ...session,
    accessToken: refreshed.accessToken,
    refreshToken: refreshed.refreshToken,
    accessTokenExpiresAt: refreshed.expiresAt,
  });

  return refreshed.accessToken;
}

Kenapa kode ini rentan:

  • Dua request bisa membaca session yang sama sebelum salah satunya menulis hasil refresh.
  • Keduanya memanggil authProvider.refresh() dengan refresh token yang sama.
  • Penyimpanan session menggunakan pola read-modify-write tanpa lock atau version check.

Jika fungsi ini dipanggil dari context GraphQL atau auth middleware untuk setiap request, race condition sangat mungkin terjadi.

Strategi Logging dan Tracing untuk Isolasi Masalah

Sebelum memperbaiki, pastikan Anda bisa membuktikan urutan kejadian. Bug concurrency sulit diselesaikan jika observability-nya lemah.

Tambahkan field log yang relevan

  • requestId atau operationId
  • sessionId atau userId yang sudah disamarkan bila perlu
  • tokenVersion atau hash pendek dari refresh token, jangan log token mentah
  • refreshAttempt dan hasilnya: hit, wait, success, failed
  • resolver name atau GraphQL operation name
  • timestamp dengan presisi milidetik

Contoh log yang membantu:

auth.check requestId=req-101 sessionId=s-77 expired=true tokenVersion=12
auth.refresh.start requestId=req-101 sessionId=s-77 tokenVersion=12
auth.check requestId=req-102 sessionId=s-77 expired=true tokenVersion=12
auth.refresh.start requestId=req-102 sessionId=s-77 tokenVersion=12
auth.refresh.success requestId=req-101 sessionId=s-77 oldTokenVersion=12 newTokenVersion=13
auth.refresh.fail requestId=req-102 sessionId=s-77 tokenVersion=12 error=invalid_refresh_token

Dari log seperti ini, race condition langsung terlihat.

Gunakan tracing lintas lapisan

Jika sistem Anda memiliki tracing terdistribusi, hubungkan span dari:

  • request GraphQL
  • auth check
  • refresh token call ke identity provider
  • read/write session atau cache

Tujuannya adalah melihat apakah beberapa span refresh untuk session yang sama berjalan bersamaan. Ini jauh lebih informatif daripada hanya melihat log 401 di edge.

Waspadai logging yang justru menutupi masalah

Kesalahan umum adalah logging terlalu tinggi levelnya, misalnya hanya mencatat Unauthorized tanpa context session dan urutan operasi. Kesalahan lain adalah menulis token mentah ke log, yang merupakan risiko keamanan. Gunakan hash atau versi token, bukan nilainya.

Perbaikan Praktis: Locking per User atau Session

Pola perbaikan paling langsung adalah memastikan hanya satu proses refresh aktif untuk satu session pada satu waktu.

Singleflight di dalam satu proses

Jika aplikasi berjalan dalam satu instance, Anda bisa memakai map promise berdasarkan sessionId. Request pertama memulai refresh, request berikutnya cukup menunggu promise yang sama.

const inFlightRefresh = new Map();

async function refreshOnce(sessionId, refreshFn) {
  if (inFlightRefresh.has(sessionId)) {
    return inFlightRefresh.get(sessionId);
  }

  const promise = (async () => {
    try {
      return await refreshFn();
    } finally {
      inFlightRefresh.delete(sessionId);
    }
  })();

  inFlightRefresh.set(sessionId, promise);
  return promise;
}

async function getValidAccessToken(sessionId) {
  const session = await sessionStore.get(sessionId);
  if (!session) throw new Error('Unauthenticated');

  if (session.accessTokenExpiresAt > Date.now()) {
    return session.accessToken;
  }

  const refreshed = await refreshOnce(sessionId, async () => {
    const latest = await sessionStore.get(sessionId);
    if (!latest) throw new Error('Unauthenticated');

    if (latest.accessTokenExpiresAt > Date.now()) {
      return {
        accessToken: latest.accessToken,
        refreshToken: latest.refreshToken,
        expiresAt: latest.accessTokenExpiresAt,
      };
    }

    const result = await authProvider.refresh({
      refreshToken: latest.refreshToken,
    });

    await sessionStore.set(sessionId, {
      ...latest,
      accessToken: result.accessToken,
      refreshToken: result.refreshToken,
      accessTokenExpiresAt: result.expiresAt,
    });

    return result;
  });

  return refreshed.accessToken;
}

Mengapa ini bekerja? Karena request kedua dan seterusnya tidak lagi memanggil endpoint refresh sendiri. Mereka menunggu hasil refresh yang sedang berjalan.

Keterbatasan: pendekatan ini hanya efektif dalam satu proses. Jika aplikasi berjalan pada banyak instance atau pod, Anda perlu koordinasi lintas proses.

Distributed lock untuk multi-instance

Pada deployment horizontal, lock perlu berada di penyimpanan bersama, misalnya Redis atau store lain yang mendukung operasi atomik. Kuncinya bisa berbentuk refresh-lock:{sessionId}.

Prinsip pentingnya:

  • lock harus memiliki TTL agar tidak menggantung selamanya jika proses crash
  • setelah mendapat lock, baca ulang session terbaru sebelum refresh
  • setelah refresh selesai, tulis state baru lalu lepaskan lock
  • request yang gagal mendapat lock bisa menunggu singkat lalu baca ulang session, bukan langsung refresh sendiri

Distributed lock membantu, tetapi implementasinya harus hati-hati. Jika lock terlalu lama, throughput turun. Jika TTL terlalu pendek, lock bisa kedaluwarsa saat refresh masih berjalan dan race condition muncul lagi.

Perbaikan Praktis: Update Session Secara Aman

Lock saja kadang belum cukup jika penyimpanan session masih memakai penulisan yang rawan overwrite. Gunakan salah satu pola berikut.

1. Compare-and-set atau version check

Simpan nomor versi pada session. Saat menulis hasil refresh, pastikan Anda masih menulis ke versi yang benar. Jika versi berubah, berarti ada request lain yang lebih dulu memperbarui state.

async function saveRefreshedSession(sessionId, expectedVersion, nextSession) {
  const updated = await sessionStore.compareAndSet(
    sessionId,
    expectedVersion,
    {
      ...nextSession,
      version: expectedVersion + 1,
    }
  );

  if (!updated) {
    throw new Error('Session changed concurrently');
  }
}

Ini mencegah last write wins yang merusak token terbaru.

2. Simpan token secara terpisah dari data session lain

Jika object session terlalu besar, update kecil di bagian lain bisa ikut menimpa token. Menyimpan auth state secara terpisah memudahkan kontrol konkurensi dan mengurangi overwrite tidak sengaja.

Token Rotation yang Aman

Jika provider auth menerapkan refresh token rotation, perlakuannya harus lebih ketat.

  • Anggap refresh token lama tidak boleh dipakai lagi setelah refresh sukses.
  • Simpan refresh token baru bersama access token baru dalam satu langkah yang konsisten.
  • Jika refresh gagal karena token lama invalid, jangan langsung menghapus session tanpa memeriksa apakah request lain baru saja berhasil refresh.

Ini poin penting: invalid refresh token tidak selalu berarti user benar-benar logout. Bisa jadi request paralel lain sudah merotasi token lebih dulu. Karena itu, pada kegagalan refresh, lakukan re-read state session terbaru sebelum memutuskan sesi harus diakhiri.

Pola Sesudah Perbaikan

Versi berikut menggabungkan beberapa prinsip: deduplikasi, baca ulang state terbaru, dan penanganan refresh failure yang lebih aman.

async function getValidAccessToken(sessionId) {
  const session = await sessionStore.get(sessionId);
  if (!session) throw new Error('Unauthenticated');

  if (session.accessTokenExpiresAt > Date.now()) {
    return session.accessToken;
  }

  const refreshed = await refreshCoordinator.run(sessionId, async () => {
    const current = await sessionStore.get(sessionId);
    if (!current) throw new Error('Unauthenticated');

    if (current.accessTokenExpiresAt > Date.now()) {
      return current.accessToken;
    }

    try {
      const result = await authProvider.refresh({
        refreshToken: current.refreshToken,
      });

      await sessionStore.updateAtomically(sessionId, current.version, {
        accessToken: result.accessToken,
        refreshToken: result.refreshToken,
        accessTokenExpiresAt: result.expiresAt,
      });

      return result.accessToken;
    } catch (err) {
      const latest = await sessionStore.get(sessionId);
      if (latest && latest.accessTokenExpiresAt > Date.now()) {
        return latest.accessToken;
      }
      throw err;
    }
  });

  return refreshed;
}

Kode ini tidak bergantung pada library tertentu, tetapi menunjukkan pola yang benar:

  • satu koordinasi refresh per session
  • cek ulang state terbaru setelah masuk jalur refresh
  • tulis hasil secara atomik
  • saat refresh gagal, periksa kemungkinan request lain sudah berhasil lebih dulu

Pengujian Concurrency yang Sering Terlewat

Banyak tim hanya menguji jalur auth secara fungsional: token expired lalu refresh berhasil. Itu belum cukup. Bug race condition perlu diuji dengan beban paralel.

Skenario uji minimal

  1. Buat session dengan access token expired dan refresh token valid.
  2. Kirim beberapa request GraphQL paralel menggunakan session yang sama.
  3. Pastikan hanya satu panggilan refresh yang benar-benar dilakukan.
  4. Pastikan semua request menerima access token valid yang konsisten.
  5. Ulangi skenario dengan refresh token rotation aktif.

Contoh pendekatan uji

it('hanya melakukan satu refresh untuk beberapa request paralel', async () => {
  const refreshSpy = jest.fn().mockResolvedValue({
    accessToken: 'new-access',
    refreshToken: 'new-refresh',
    expiresAt: Date.now() + 60_000,
  });

  authProvider.refresh = refreshSpy;

  await Promise.all([
    executeGraphQLRequest(sessionId),
    executeGraphQLRequest(sessionId),
    executeGraphQLRequest(sessionId),
    executeGraphQLRequest(sessionId),
  ]);

  expect(refreshSpy).toHaveBeenCalledTimes(1);
});

Selain unit test, pertimbangkan integration test dengan storage yang benar-benar Anda pakai. Race condition sering lolos jika test hanya memakai in-memory mock yang terlalu sederhana.

Kesalahan Umum Saat Memperbaiki

  • Lock global untuk semua user. Ini memang menghentikan race, tetapi membuat bottleneck besar.
  • Retry buta pada refresh gagal. Jika akar masalahnya concurrency, retry tanpa koordinasi hanya memperparah lonjakan.
  • Menghapus session terlalu cepat saat refresh token invalid. Selalu cek apakah state session sudah diperbarui request lain.
  • Mengandalkan cache TTL saja tanpa atomic update. TTL tidak menyelesaikan masalah write collision.
  • Menyimpan token mentah di log. Ini membantu debugging sesaat, tetapi membuka risiko keamanan serius.

Checklist Perbaikan di Sistem Nyata

  • Pastikan keputusan refresh tidak dilakukan bersamaan untuk session yang sama.
  • Terapkan singleflight atau lock per user/session, bukan lock global.
  • Baca ulang session setelah mendapat lock atau masuk jalur deduplikasi.
  • Gunakan update atomik, compare-and-set, atau versioning untuk state auth.
  • Tangani refresh token rotation sebagai state yang sensitif terhadap urutan.
  • Tambahkan logging terstruktur dengan requestId, sessionId, dan versi token.
  • Uji concurrency dengan request paralel, bukan hanya skenario tunggal.
  • Tinjau retry policy di client, gateway, dan backend agar tidak memicu amplifikasi.

Penutup

Debugging GraphQL race condition pada refresh token resolver hampir selalu berujung pada satu pelajaran: validasi token boleh stateless, tetapi proses refresh biasanya tidak. Begitu ada session bersama, refresh token rotation, cache, atau beberapa request paralel, Anda membutuhkan koordinasi yang eksplisit.

Solusi yang paling praktis biasanya kombinasi dari deduplikasi refresh per session, update state yang atomik, dan observability yang cukup untuk melihat urutan kejadian. Dengan tiga hal itu, gejala seperti 401 acak, token tertimpa, dan retry berantai biasanya bisa dihentikan tanpa mengubah arsitektur GraphQL secara besar-besaran.