Retry-safe OAuth token refresh bukan sekadar menambahkan retry saat menerima 401 Unauthorized. Pada integrasi API yang berjalan paralel, banyak worker bisa mencoba me-refresh token yang sama secara bersamaan, menimpa hasil refresh yang lebih baru, atau memperparah kegagalan dengan retry berantai. Di Rust, masalah ini perlu diselesaikan pada level kontrak API klien, koordinasi konkurensi, dan manajemen state token.
Artikel ini fokus pada desain yang praktis: kapan refresh dipicu, bagaimana mendeduplikasi refresh paralel, bagaimana mencegah token usang tertulis kembali, bagaimana memberi timeout dan backoff, serta bagaimana membedakan 401 dan 403 agar retry tidak salah sasaran. Penjelasan OAuth dasar sengaja diminimalkan; yang dibahas adalah masalah integrasi nyata di production.
Mengapa refresh token sering gagal di sistem nyata
Pola yang paling sering bermasalah adalah:
- Setiap request yang mendapat
401langsung me-refresh token sendiri. - Beberapa worker menyimpan token dalam cache lokal tanpa koordinasi.
- Retry otomatis di HTTP client atau job runner ikut mengulang refresh yang sebenarnya tidak boleh diulang sembarangan.
- Hasil refresh lama menimpa token baru karena write order tidak dijaga.
Akibatnya:
- Thundering herd: puluhan request paralel memukul endpoint token sekaligus.
- Lost update: token hasil refresh terbaru tertimpa state lama.
- Retry amplification: satu gangguan kecil berkembang menjadi banjir retry.
- Auth loop: request bisnis gagal karena klien terus mengulang dengan token yang salah.
Desain yang aman harus memastikan bahwa refresh adalah operasi yang coordinated, bukan perilaku spontan per request.
Prinsip desain retry-safe OAuth token refresh
1. Pisahkan request bisnis dari logika otorisasi
Bungkus akses API dalam komponen yang bertanggung jawab atas:
- membaca token aktif,
- menentukan apakah token masih layak dipakai,
- menjalankan refresh bila diperlukan,
- mengulang request bisnis sekali setelah refresh sukses.
Jangan sebarkan logika refresh ke banyak call site. Bila setiap endpoint punya logika retry sendiri, race condition akan sulit dikendalikan.
2. Refresh harus bersifat singleflight
Untuk satu identitas token yang sama, hanya boleh ada satu refresh aktif pada satu waktu. Request lain harus:
- menunggu hasil refresh tersebut, atau
- gagal cepat bila context tidak memungkinkan menunggu.
Ini inti dari deduplikasi request paralel.
3. Token state harus punya versi atau generasi
Menyimpan hanya access_token dan expires_at sering tidak cukup. Tambahkan generation atau version agar update token bisa memakai aturan compare-and-set. Tujuannya: hasil refresh lama tidak boleh menimpa state yang sudah lebih baru.
4. Refresh jangan dipicu hanya oleh 401
Refresh sebaiknya bisa dipicu oleh dua kondisi:
- Proaktif: token mendekati kedaluwarsa, misalnya ada safety window sebelum
expires_at. - Reaktif: request mendapat
401yang memang menunjukkan access token tidak valid atau kedaluwarsa.
Refresh proaktif mengurangi lonjakan 401 saat token habis bersamaan di banyak worker.
5. Retry harus terbatas dan sadar konteks
Refresh token adalah operasi sensitif. Jangan ulang tanpa batas. Tetapkan:
- batas maksimum percobaan,
- backoff dengan jitter,
- timeout untuk request refresh,
- aturan error mana yang layak di-retry dan mana yang tidak.
Kontrak API yang disarankan
Daripada mengekspos get_token() lalu membiarkan caller membuat header sendiri, lebih aman menyediakan kontrak tingkat lebih tinggi:
async fn send_authorized(req: ApiRequest) -> Result<ApiResponse, ClientError>Dengan kontrak ini, komponen auth dapat mengendalikan seluruh siklus:
- ambil token aktif,
- cek apakah token perlu refresh proaktif,
- kirim request dengan token tersebut,
- bila respon
401, validasi apakah layak refresh, - refresh secara singleflight,
- ulang request satu kali dengan token baru.
Keuntungannya:
- caller tidak perlu tahu detail refresh,
- retry tidak menyebar ke banyak tempat,
- observabilitas lebih mudah karena semua request lewat satu lapisan.
Error model yang berguna
Gunakan error yang cukup ekspresif agar caller bisa mengambil tindakan yang benar.
enum ClientError {
Unauthorized,
Forbidden,
RefreshFailed(RefreshError),
UpstreamTimeout,
UpstreamUnavailable,
Network(String),
InvalidResponse(String),
}
enum RefreshError {
InvalidGrant,
TemporarilyUnavailable,
Timeout,
Network(String),
ConcurrentRefreshLost,
Storage(String),
}401 dan 403 jangan diperlakukan sama:
401 Unauthorizedbiasanya berarti token tidak ada, tidak valid, kedaluwarsa, atau ditolak. Ini kandidat untuk refresh, tetapi tetap perlu aturan agar tidak semua 401 memicu refresh tanpa cek.403 Forbiddenbiasanya berarti token valid, tetapi tidak punya izin. Refresh biasanya tidak akan memperbaiki masalah ini, jadi jangan retry dengan asumsi token kedaluwarsa.
Jika provider memberi body error yang lebih spesifik, gunakan untuk memperbaiki keputusan refresh. Namun, jangan mengandalkan field yang tidak stabil atau tidak terdokumentasi.
Struktur penyimpanan token yang aman
Untuk kasus sederhana, struktur token dapat memuat:
use std::time::SystemTime;
#[derive(Clone, Debug)]
struct TokenState {
access_token: String,
refresh_token: String,
expires_at: SystemTime,
generation: u64,
}
Elemen pentingnya:
expires_at: untuk refresh proaktif.generation: untuk mencegah update lama menimpa update baru.refresh_token: beberapa provider memutar refresh token pada setiap refresh; jangan asumsi nilainya tetap.
Aturan update state
Saat refresh dimulai, simpan generation yang menjadi dasar proses tersebut. Setelah hasil refresh diterima, tulis state baru hanya bila generation saat ini masih sama dengan generation awal.
Contoh logika:
- State saat ini generation = 10.
- Worker A mulai refresh berdasarkan generation 10.
- Worker B juga ingin refresh, tetapi harus menunggu singleflight, bukan memulai refresh kedua.
- Jika karena suatu alasan refresh lain sudah lebih dulu menyelesaikan update ke generation 11, maka hasil kerja A yang masih berbasis generation 10 tidak boleh menimpa state generation 11.
Ini mencegah stale overwrite.
Singleflight dan deduplikasi refresh paralel di Rust
Di Rust async, pendekatan umum adalah menyimpan state token di dalam Arc dan mengoordinasikan refresh dengan Mutex, RwLock, Notify, atau channel. Tujuannya bukan sekadar mutual exclusion, melainkan memastikan hanya satu future yang mengeksekusi refresh untuk satu token scope tertentu.
Pola state manager
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::sync::{Mutex, Notify};
struct SharedAuthState {
inner: Mutex<InnerAuthState>,
notify: Notify,
}
struct InnerAuthState {
token: Option<TokenState>,
refresh_in_flight: bool,
}
impl SharedAuthState {
async fn get_valid_token(&self, skew: Duration) -> Result<TokenState, ClientError> {
loop {
let mut guard = self.inner.lock().await;
if let Some(token) = &guard.token {
if !is_expiring(token, skew) {
return Ok(token.clone());
}
}
if guard.refresh_in_flight {
drop(guard);
self.notify.notified().await;
continue;
}
guard.refresh_in_flight = true;
let base_generation = guard.token.as_ref().map(|t| t.generation).unwrap_or(0);
let current = guard.token.clone();
drop(guard);
let refresh_result = refresh_token(current).await;
let mut guard = self.inner.lock().await;
guard.refresh_in_flight = false;
match refresh_result {
Ok(new_token) => {
let should_write = match &guard.token {
Some(existing) => existing.generation == base_generation,
None => base_generation == 0,
};
if should_write {
guard.token = Some(new_token.clone());
self.notify.notify_waiters();
return Ok(new_token);
} else {
self.notify.notify_waiters();
return guard.token.clone().ok_or(ClientError::RefreshFailed(
RefreshError::ConcurrentRefreshLost,
));
}
}
Err(err) => {
self.notify.notify_waiters();
return Err(ClientError::RefreshFailed(err));
}
}
}
}
}
fn is_expiring(token: &TokenState, skew: Duration) -> bool {
match token.expires_at.duration_since(SystemTime::now()) {
Ok(left) => left <= skew,
Err(_) => true,
}
}Poin penting dari pola di atas:
- Lock tidak ditahan saat network call refresh. Ini penting agar tidak memblokir semua request selama menunggu HTTP.
- refresh_in_flight bertindak sebagai penanda singleflight.
- Notify dipakai untuk membangunkan waiter setelah refresh selesai.
- base_generation digunakan agar hasil refresh lama tidak menimpa state baru.
Kapan cukup pakai lock lokal, kapan perlu koordinasi terdistribusi
Jika aplikasi berjalan sebagai satu proses atau satu instance, singleflight lokal biasanya cukup. Namun jika ada banyak instance service atau banyak worker terpisah yang berbagi kredensial OAuth yang sama, lock lokal tidak cukup.
Pada skenario multi-instance, pertimbangkan:
- penyimpanan token terpusat,
- optimistic concurrency dengan version/generation di database atau cache,
- distributed lock dengan TTL pendek bila benar-benar perlu.
Trade-off:
- Distributed lock membantu deduplikasi lintas instance, tetapi menambah kompleksitas dan potensi deadlock/expiry race bila salah diatur.
- Optimistic concurrency lebih sederhana dan sering cukup, asalkan stale write bisa ditolak dengan compare-and-set.
Untuk banyak sistem, kombinasi terbaik adalah: refresh boleh terjadi lebih dari satu kali lintas instance, tetapi hanya hasil dengan version yang benar yang boleh tersimpan. Dengan cara ini, sistem tetap konsisten walau deduplikasi tidak sempurna.
Kapan refresh dipicu
Refresh proaktif
Gunakan safety window sebelum kedaluwarsa, misalnya beberapa detik atau menit tergantung latensi, antrean kerja, dan durasi request. Tujuan utamanya:
- mengurangi kegagalan request saat token habis di tengah jalan,
- mencegah ledakan 401 serentak,
- memberi ruang untuk retry refresh tanpa mengorbankan request bisnis.
Jangan set window terlalu agresif, karena bisa membuat refresh terlalu sering.
Refresh reaktif saat 401
Setelah request mendapat 401, jangan langsung asumsi refresh wajib dilakukan. Gunakan aturan seperti ini:
- Jika request memakai token yang memang sudah lama atau hampir expired, refresh masuk akal.
- Jika token yang dipakai bukan token generation terbaru, ambil token terbaru dan coba ulang satu kali tanpa refresh tambahan.
- Jika baru saja refresh namun masih mendapat 401, kemungkinan masalahnya bukan token kedaluwarsa; hentikan loop retry.
Ini penting untuk menghindari situasi di mana retry terus memanggil endpoint refresh padahal kredensial klien salah, refresh token telah dicabut, atau provider sedang bermasalah.
Alur request yang disarankan
- Caller memanggil
send_authorized(). - Auth manager mengambil token aktif.
- Jika token mendekati kedaluwarsa, lakukan refresh singleflight.
- Kirim request dengan access token aktif.
- Jika respon sukses, selesai.
- Jika respon
401: - cek apakah token yang dipakai masih generation terbaru,
- jika bukan, ulang request satu kali dengan token terbaru,
- jika ya, lakukan refresh singleflight,
- ulang request satu kali dengan token baru.
- Jika respon
403, map keForbiddendan jangan refresh otomatis. - Jika refresh gagal sementara, gunakan backoff terbatas.
- Jika refresh gagal permanen seperti
invalid_grant, hentikan retry dan minta re-authorization atau intervensi operator.
Contoh wrapper request async di Rust
async fn send_authorized(
auth: Arc<SharedAuthState>,
req: ApiRequest,
) -> Result<ApiResponse, ClientError> {
let token = auth.get_valid_token(std::time::Duration::from_secs(30)).await?;
let first = send_with_token(&req, &token.access_token).await?;
match first.status {
200..=299 => Ok(first),
401 => {
let refreshed = auth.get_valid_token(std::time::Duration::from_secs(0)).await?;
if refreshed.generation == token.generation {
// get_valid_token tidak memaksa refresh jika token masih terlihat valid.
// Pada jalur 401, Anda bisa sediakan fungsi force_refresh_singleflight().
}
let newer = auth.force_refresh_if_generation(token.generation).await?;
let second = send_with_token(&req, &newer.access_token).await?;
match second.status {
200..=299 => Ok(second),
401 => Err(ClientError::Unauthorized),
403 => Err(ClientError::Forbidden),
_ => map_status(second.status),
}
}
403 => Err(ClientError::Forbidden),
_ => map_status(first.status),
}
}Catatan desain:
- Ulang request maksimal satu kali setelah refresh. Lebih dari itu biasanya hanya menambah beban dan menyamarkan akar masalah.
- Fungsi seperti
force_refresh_if_generation(old_generation)berguna agar request 401 tidak memicu refresh ganda bila token sudah diperbarui oleh request lain.
Backoff, timeout, dan retry yang tidak memperparah kegagalan
Timeout
Request refresh harus memiliki timeout yang ketat. Tanpa timeout, waiter bisa menumpuk dan membuat seluruh jalur request ikut macet. Gunakan timeout terpisah untuk:
- koneksi/TLS,
- respon endpoint token,
- penulisan state ke storage bila memakai backend eksternal.
Jangan menahan lock selama menunggu timeout network.
Backoff dengan jitter
Jika endpoint token mengembalikan error sementara, gunakan exponential backoff dengan jitter agar banyak worker tidak retry pada saat yang sama. Bahkan jika Anda sudah memiliki singleflight lokal, jitter tetap berguna untuk koordinasi lintas instance.
Error yang umumnya layak di-retry secara terbatas:
- timeout jaringan,
- kegagalan koneksi sementara,
- respons upstream yang menunjukkan gangguan sementara.
Error yang umumnya tidak layak di-retry berkali-kali:
invalid_grant,- refresh token dicabut,
- kredensial klien salah,
- error validasi request refresh.
Hindari retry amplification
Masalah klasik di production adalah beberapa lapisan melakukan retry sendiri:
- HTTP client retry,
- auth layer retry refresh,
- job worker retry task,
- gateway atau service mesh retry request keluar.
Jika semuanya aktif bersamaan, satu kegagalan bisa berlipat ganda. Tentukan satu lapisan utama yang bertanggung jawab atas retry refresh, dan buat lapisan lain lebih konservatif atau nonaktif untuk jalur token.
Mapping error 401 dan 403 dengan benar
401 Unauthorized
Map ke jalur yang memungkinkan:
- ambil token terbaru bila request mungkin memakai token usang,
- refresh bila token terbaru masih sama dan ada indikasi token invalid/kedaluwarsa,
- gagal permanen bila sesudah refresh tetap 401.
Jangan buat loop: 401 -> refresh -> retry -> 401 -> refresh tanpa batas.
403 Forbidden
Map ke error otorisasi/izin. Secara umum:
- jangan refresh otomatis,
- sertakan konteks yang cukup untuk debugging, seperti scope, resource, atau endpoint yang ditolak.
Refresh biasanya tidak mengubah izin token yang sudah diterbitkan untuk klien tersebut.
Edge case yang wajib diantisipasi
Banyak worker menyegarkan token yang sama
Ini terjadi saat kredensial OAuth dipakai bersama oleh banyak proses. Solusinya:
- singleflight lokal per instance,
- versioned storage agar stale write ditolak,
- bila perlu, koordinasi terdistribusi ringan.
Tujuan utamanya bukan mencegah refresh ganda 100%, melainkan mencegah state akhir menjadi salah.
Token tertimpa hasil refresh lama
Ini salah satu bug paling berbahaya karena gejalanya tidak konsisten. Gunakan:
generationatauupdated_atmonotonic,- compare-and-set saat menulis state,
- validasi bahwa refresh result masih relevan sebelum disimpan.
Refresh token ikut berotasi
Beberapa provider mengembalikan refresh token baru setiap kali refresh. Jika Anda hanya memperbarui access token dan lupa menyimpan refresh token baru secara atomik, refresh berikutnya bisa gagal. Simpan pasangan access_token dan refresh_token sebagai satu unit state.
Request panjang memakai token yang keburu kedaluwarsa
Walau token masih valid saat request dikirim, bisa saja kedaluwarsa di tengah proses. Karena itu safety window untuk refresh proaktif perlu mempertimbangkan durasi request terburuk yang realistis.
401 karena alasan selain expired token
Misalnya audience salah, token dibatalkan, atau server upstream punya bug. Jika Anda selalu menganggap 401 berarti harus refresh, Anda akan membebani endpoint token tanpa memperbaiki masalah.
Observabilitas dan debugging
Mekanisme refresh yang baik harus mudah diamati. Minimal catat metrik dan log berikut:
- jumlah refresh sukses/gagal,
- jumlah waiter yang menunggu refresh in-flight,
- jumlah 401 sebelum refresh dan sesudah refresh,
- jumlah retry refresh dan hasil akhirnya,
- latensi endpoint token,
- berapa kali stale write ditolak oleh compare-and-set.
Untuk logging:
- jangan pernah log access token atau refresh token utuh,
- boleh log fingerprint atau suffix pendek yang aman bila benar-benar perlu korelasi,
- sertakan generation token, request id, dan alasan refresh.
Untuk tracing:
- buat span terpisah untuk request bisnis dan refresh token,
- hubungkan request yang menunggu singleflight ke span refresh aktif.
Jika Anda melihat pola banyak
401diikuti refresh sukses, kemungkinan refresh proaktif terlalu terlambat. Jika refresh gagal melonjak bersamaan dengan timeout, periksa timeout, DNS, koneksi keluar, dan retry berlapis.
Checklist implementasi production-ready
- Satu jalur resmi untuk request terotorisasi, misalnya
send_authorized(). - Refresh proaktif dengan safety window yang masuk akal.
- Refresh reaktif saat 401, tetapi hanya satu retry request setelah refresh.
- Singleflight untuk deduplikasi refresh paralel.
- Version/generation pada state token untuk mencegah stale overwrite.
- Atomic update untuk access token dan refresh token.
- Timeout khusus pada jalur refresh.
- Backoff + jitter untuk kegagalan sementara.
- Error mapping 401/403 yang jelas dan tidak ambigu.
- Retry policy yang tidak tumpang tindih dengan lapisan lain.
- Observabilitas: metrics, tracing, dan log yang aman.
- Pengujian konkurensi untuk race condition, bukan hanya unit test biasa.
Strategi pengujian yang layak
Karena bug auth sering bersifat timing-sensitive, tambahkan skenario uji seperti:
- 10-100 request paralel saat token hampir expired, pastikan hanya satu refresh aktif per instance.
- refresh lambat yang selesai setelah token sudah diperbarui oleh proses lain, pastikan stale write ditolak.
- 401 beruntun setelah refresh, pastikan tidak terjadi loop retry.
- endpoint token timeout, pastikan waiter dibangunkan dan error dimap dengan benar.
- provider mengembalikan refresh token baru, pastikan state tersimpan atomik.
Jika memungkinkan, gunakan tes integrasi dengan upstream palsu yang bisa mensimulasikan:
- latensi tinggi,
- 401/403 terkontrol,
- rotasi refresh token,
- kegagalan sementara dan permanen.
Penutup
Di Rust, retry-safe OAuth token refresh untuk integrasi API sebaiknya diperlakukan sebagai masalah koordinasi state dan konkurensi, bukan sekadar masalah HTTP retry. Solusi yang kuat biasanya menggabungkan refresh proaktif, singleflight, versioned token state, retry terbatas, timeout, serta mapping 401/403 yang ketat.
Jika Anda hanya mengambil satu prinsip dari artikel ini, ambil yang ini: jangan biarkan setiap request memutuskan refresh sendiri. Pusatkan keputusan refresh, deduplikasi eksekusinya, dan lindungi state token dari update usang. Dengan itu, integrasi OAuth Anda jauh lebih stabil saat sistem mulai sibuk, gagal sebagian, atau berjalan paralel di banyak worker.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!