Sinkronisasi metrik repositori GitHub seperti riwayat stargazer sering mengalami kegagalan mendadak ketika repositori bertumbuh pesat. Masalah ini umumnya bermanifestasi sebagai worker job timeout, response HTTP 403 Forbidden, atau data bintang terpotong sebagian (silent payload truncation).
Artikel ini membedah akar permasalahan dari restriksi API GitHub, pagination, dan limit kuota token, serta menyajikan solusi arsitektural konkret menggunakan Node.js dan TypeScript.
Gejala Kegagalan Sinkronisasi Backend
Saat background worker (seperti BullMQ, Celery, atau Temporal) mengeksekusi sinkronisasi repositori populer, terdapat tiga anomali utama yang kerap muncul pada log sistem:
- Job Worker Timeout: Eksekusi worker melampaui batas toleransi (misalnya 60 detik) karena loop pagination sinkron mengeksekusi ratusan HTTP request secara berurutan.
- HTTP 403 Forbidden: Respon mengembalikan pesan
API rate limit exceededatausecondary rate limitakibat volume request per menit yang terlalu padat dari IP atau token yang sama. - Silent Payload Truncation: Background process selesai dengan exit status sukses, namun total stargazer yang tersimpan di database lebih sedikit dibanding angka aslinya. Hal ini terjadi karena REST API GitHub v3 membatasi pagination hingga maksimal 40.000 data pada beberapa endpoint relasional, atau pemutusan pagination loop tanpa validasi header
Link.
Analisis Root Cause: Rate Limit dan Perubahan Pagination
GitHub menerapkan dua kategori limitasi pada REST API:
- Primary Rate Limit: Pengguna terotentikasi Personal Access Token (PAT) standar mendapatkan kuota 5.000 request per jam. Untuk repositori dengan 50.000 bintang (100 item/halaman = 500 request), satu sinkronisasi penuh menghabiskan 10% kuota total.
- Secondary Rate Limit: Algoritma anti-abusive GitHub memblokir klien yang mengirimkan lebih dari 100 concurrent request atau melakukan polling agresif dalam hitungan detik, terlepas dari sisa kuota primer.
- Batas Pagination REST: REST API GitHub v3 menggunakan query parameter
pagedanper_page(maksimal 100). Saat data menembus puluhan ribu entri, traversal halaman dalam (deep pagination) meningkatkan latensi server GitHub secara drastis, berisiko mengembalikan HTTP 502 atau koneksi terputus di tengah streaming respons.
Solusi 1: Robust Error Handling dan Parsing Rate Limit Header
Setiap response dari GitHub menyertakan header telemetri kuota. Backend harus memeriksa header ini sebelum melakukan request berikutnya daripada sekadar menangkap blok catch biasa.
interface RateLimitMetadata {
limit: number;
remaining: number;
resetTimestamp: number;
}
function extractRateLimits(headers: Headers): RateLimitMetadata {
return {
limit: Number(headers.get('x-ratelimit-limit') ?? 0),
remaining: Number(headers.get('x-ratelimit-remaining') ?? 0),
resetTimestamp: Number(headers.get('x-ratelimit-reset') ?? 0) * 1000,
};
}Jika menerima status 403 atau 429, evaluasi header retry-after jika tersedia. Jika tidak ada, gunakan selisih waktu x-ratelimit-reset terhadap waktu server saat ini.
Solusi 2: Adaptive Backoff dengan Full Jitter
Untuk menghindari thundering herd problem saat terkena secondary rate limit, implementasikan exponential backoff yang dilengkapi variasi acak (jitter). Pola Full Jitter mencegah beberapa worker mencoba kembali secara bersamaan pada interval waktu yang seragam.
async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function calculateBackoffWithJitter(attempt: number, baseMs = 1000, capMs = 30000): number {
const maxSleep = Math.min(capMs, baseMs * Math.pow(2, attempt));
return Math.floor(Math.random() * maxSleep);
}
async function fetchWithRetry(url: string, headers: Record<string, string>, maxAttempts = 3): Promise<Response> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(url, { headers });
if (res.status !== 403 && res.status !== 429) {
return res;
}
const retryAfter = res.headers.get('retry-after');
const delayMs = retryAfter
? Number(retryAfter) * 1000
: calculateBackoffWithJitter(attempt);
await sleep(delayMs);
}
throw new Error(`Request gagal setelah ${maxAttempts} percobaan.`);
}Solusi 3: Token Pool Rotation
Ketika volume sinkronisasi tinggi, satu token PAT tidak memadai. Buat token manager sederhana berbasis round-robin atau state kuota untuk mendistribusikan konsumsi limit.
class TokenBucketManager {
private tokens: Array<{ token: string; remaining: number; resetAt: number }>;
private index = 0;
constructor(rawTokens: string[]) {
this.tokens = rawTokens.map(t => ({ token: t, remaining: 5000, resetAt: 0 }));
}
public getAvailableToken(): string {
const now = Date.now();
for (let i = 0; i < this.tokens.length; i++) {
const item = this.tokens[this.index];
this.index = (this.index + 1) % this.tokens.length;
if (item.remaining > 10 || item.resetAt < now) {
return item.token;
}
}
throw new Error('Seluruh token dalam pool telah mencapai limit.');
}
public updateUsage(tokenStr: string, remaining: number, resetAtMs: number): void {
const target = this.tokens.find(t => t.token === tokenStr);
if (target) {
target.remaining = remaining;
target.resetAt = resetAtMs;
}
}
}Solusi 4: Beralih ke GitHub GraphQL Cursor-Based Pagination
REST API stargazer (Accept: application/vnd.github.v3.star+json) membebani limit per request. Alternatif yang lebih tahan terhadap skema pagination besar adalah beralih ke GitHub GraphQL API v4. GraphQL menyediakan kontrol cursor presisi (after), memuat hingga 100 simpul per query, dan memiliki alokasi point rate limit terpisah (5.000 poin per jam berdasarkan kompleksitas query).
query GetStargazers($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
stargazers(first: 100, after: $cursor, orderBy: {field: STARRED_AT, direction: ASC}) {
totalCount
pageInfo {
endCursor
hasNextPage
}
edges {
starredAt
cursor
node {
login
id
}
}
}
}
}Strategi Penyimpanan Database
Hindari transaksi database yang menahan koneksi selama loop jaringan berlangsung. Terapkan pola akumulasi batch:
- Tarik data per 100 item menggunakan cursor.
- Lakukan
UPSERT(misal:INSERT ... ON CONFLICT DO NOTHINGdi PostgreSQL) setiap 1.000 rekaman ke tabel repositori. - Simpan
endCursorterakhir di tabel state checkpoint. Jika job worker terputus oleh limit atau timeout platform, sinkronisasi berikutnya cukup melanjutkan dari cursor terakhir tanpa perlu mengulang dari record nol.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!