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 rateLimit langsung 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 True

Trade-offs dan Rekomendasi

Membatasi konkurensi memperpanjang waktu agregasi data profil pengguna pertama kali. Untuk mengatasi trade-off latensi ini:

  1. Simpan snapshot hasil query agregasi ke Redis/database lokal dengan TTL terukur (misal: 6-12 jam).
  2. Gunakan strategi pembaruan data asinkron via background worker (stale-while-revalidate), sehingga user interface tidak memicu query langsung ke GitHub API secara on-demand.