Layanan agregasi metrik profil publik GitHub sering mengalami kegagalan saat memproses repositori dalam jumlah besar. Gejala utamanya adalah lonjakan eror HTTP 403 Forbidden yang disertai pesan Secondary Rate Limit dan HTTP 504 Gateway Timeout. Artikel ini membedah akar masalah teknis pada query GraphQL dan solusi arsitektur backend untuk mengatasinya.
Gejala Masalah: HTTP 403 dan Eksekusi Timeout
Saat worker mengagregasi data untuk kebutuhan card generator, sistem mengeksekusi batch request ke GraphQL endpoint (https://api.github.com/graphql). Pada beban tinggi, GitHub mengembalikan respons berikut:
HTTP/2 403 Forbidden
content-type: application/json; charset=utf-8
x-ratelimit-remaining: 4820
retry-after: 60
{
"message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again.",
"documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits"
}Nilai x-ratelimit-remaining masih tinggi, namun request langsung ditolak. Hal ini menandakan worker melanggar batas konkurensi atau memicu komputasi CPU berlebih pada infrastruktur GitHub dalam jendela waktu singkat.
Analisis Root Cause
1. Pagination Rekursif Tak Terkontrol
Worker sering mengeksekusi pagination traversal secara paralel tanpa batas kedalaman (depth limit). Jika sebuah akun memiliki 200 repositori dan setiap repositori mengambil 100 pull request serta commit history, proses menghasilkan ratusan request per detik dari satu worker IP atau PAT (Personal Access Token).
2. Unhandled Point Cost pada GraphQL
GitHub GraphQL API tidak menghitung 1 HTTP request sebagai 1 poin kuota. Biaya dihitung menggunakan kalkulasi node. Node connection bersarang melipatgandakan poin secara eksponensial. Contoh query bermasalah:
query GetUserData($login: String!) {
user(login: $login) {
repositories(first: 100) {
nodes {
pullRequests(first: 50) { # 100 * 50 = 5.000 nodes
nodes {
commits(first: 10) { # 5.000 * 10 = 50.000 nodes
totalCount
}
}
}
}
}
}
}Query di atas memicu kalkulasi ribuan node sekaligus, menyebabkan timeout internal GitHub atau aktivasi proteksi sekunder otomatis.
Langkah Investigasi Respons Header
Lakukan audit response header pada layer HTTP client worker sebelum query dieksekusi ulang:
x-ratelimit-limit: Total kuota primer per jam (standar 5.000 untuk PAT).x-ratelimit-remaining: Sisa kuota poin GraphQL primer.x-ratelimit-reset: Epoch timestamp kapan kuota primer di-reset.retry-after: Durasi penalti (dalam detik) jika terkena secondary rate limit.- Periksa objek
rateLimitlangsung di payload GraphQL:
query CheckCost {
rateLimit {
limit
cost
remaining
resetAt
}
}Mitigasi dan Solusi Teknis
1. Normalisasi Query dan Flattening
Hindari nesting multi-level. Pecah query menjadi beberapa tahap sekuensial yang terisolasi. Gunakan flat query dan simpan state kursor (cursor pagination) di persistent queue.
2. Implementasi Token Bucket Terdistribusi (Redis)
Gunakan Redis untuk memastikan worker tidak melebihi batasan konkurensi (misal: maksimal 1 request per detik per token) untuk menghindari secondary limit.
class TokenBucketRateLimiter:
def __init__(self, redis_client, key, capacity, fill_rate):
self.redis = redis_client
self.key = key
self.capacity = capacity
self.fill_rate = fill_rate # token per detik
def consume(self) -> bool:
lua_script = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last_updated')
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])
if not tokens then
tokens = capacity
last_updated = now
else
local delta = math.max(0, now - last_updated)
tokens = math.min(capacity, tokens + delta * fill_rate)
last_updated = now
end
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated)
return 0
end
"""
import time
result = self.redis.eval(lua_script, 1, self.key, self.capacity, self.fill_rate, time.time())
return bool(result)3. Circuit Breaker Pattern
Bungkus HTTP client dengan Circuit Breaker. Saat status 403 atau header retry-after diterima, putus sirkuit (state: OPEN) untuk menahan seluruh job queue dari eksekusi request ke GitHub sampai interval cooldown selesai.
import time
class GitHubCircuitBreaker:
def __init__(self):
self.state = "CLOSED"
self.cooldown_until = 0
def handle_response(self, status_code: int, headers: dict):
if status_code == 403 and "retry-after" in headers:
retry_seconds = int(headers["retry-after"])
self.state = "OPEN"
self.cooldown_until = time.time() + retry_seconds
elif status_code == 200 and self.state == "HALF-OPEN":
self.state = "CLOSED"
def can_execute(self) -> bool:
if self.state == "OPEN":
if time.time() >= self.cooldown_until:
self.state = "HALF-OPEN"
return True
return False
return TrueTrade-offs dan Rekomendasi
Membatasi konkurensi memperpanjang waktu agregasi data profil pengguna pertama kali. Untuk mengatasi trade-off latensi ini:
- Simpan snapshot hasil query agregasi ke Redis/database lokal dengan TTL terukur (misal: 6-12 jam).
- Gunakan strategi pembaruan data asinkron via background worker (stale-while-revalidate), sehingga user interface tidak memicu query langsung ke GitHub API secara on-demand.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!