midudev/autoskills · error · Error

GitHub 403 rate limit exceeded

Error message

GitHub 403 rate limit exceeded${resetSuffix} for ${url}. Set GITHUB_TOKEN or GH_TOKEN to increase the limit.

What it means

ghFetch wraps GitHub API calls in the sync-skills script. A 403 status combined with x-ratelimit-remaining: 0 means GitHub's rate limit is exhausted for the current identity (unauthenticated IP or the configured token). The script throws a dedicated error including the reset time and the offending URL, since retrying before reset is pointless.

Solutions

  1. Set GITHUB_TOKEN (or GH_TOKEN) to a valid personal access token before running the sync script.
  2. Wait until the reset timestamp in the error message, then re-run the sync.
  3. Verify an existing GITHUB_TOKEN is valid and unexpired (a bad token can fall back to anonymous limits).
  4. Reduce sync frequency or scope (fewer repos/skills per run) to stay under the limit.

Example fix

// before
$ node scripts/sync-skills.mjs   // 403, rate limit exhausted

// after
$ GITHUB_TOKEN=ghp_xxx node scripts/sync-skills.mjs
Defensive patterns

Strategy: retry

Validate before calling

const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (!token) throw new Error("Set GITHUB_TOKEN before running the sync script");
// optionally pre-check budget
const rl = await fetch("https://api.github.com/rate_limit", { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json());
if (rl.resources.core.remaining < 20) throw new Error(`Low GitHub quota: ${rl.resources.core.remaining}`);

Try / catch

import { setTimeout as sleep } from "node:timers/promises";
async function ghFetchWithRetry(url, tries = 2) {
  try {
    return await ghFetch(url);
  } catch (e) {
    const m = e.message.match(/resets (.+?)\)/);
    if (m && tries > 0) {
      await sleep(Math.max(0, new Date(m[1]).getTime() - Date.now()) + 1000);
      return ghFetchWithRetry(url, tries - 1);
    } else throw e;
  }
}

Prevention

When it happens

Trigger: Any ghFetch(url) call during the sync (listing repo trees, fetching skill files via resolveRepoHead etc.) returning 403 with the rate-limit-remaining header at 0 — typical for large syncs without GITHUB_TOKEN set, or a token that has hit its limit.

Common situations: Bulk skill sync from CI shared IPs without a token (60 req/hr unauthenticated); an expired or revoked GITHUB_TOKEN silently treated as anonymous; many rapid sync runs in succession.

Related errors


AI-assisted analysis of midudev/autoskills@0ec725320d (2026-09-15). Data as JSON: /api/errors/5f5da7a704ef5f13. Report an issue: GitHub.

Appendix: source

Thrown at packages/autoskills/scripts/sync-skills.mjs:157

  }
  return byRepo;
}

// ── GitHub helpers ───────────────────────────────────────────

async function ghFetch(url) {
  const headers = {
    "User-Agent": "autoskills-sync",
    Accept: "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
  };
  if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}`;
  const res = await fetch(url, { headers });
  if (!res.ok) {
    const resetAt = Number(res.headers.get("x-ratelimit-reset") || 0) * 1000;
    const resetSuffix = resetAt ? ` (resets ${new Date(resetAt).toISOString()})` : "";
    if (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0") {
      throw new Error(
        `GitHub 403 rate limit exceeded${resetSuffix} for ${url}. Set GITHUB_TOKEN or GH_TOKEN to increase the limit.`,
      );
    }
    throw new Error(`GitHub ${res.status} ${res.statusText} for ${url}`);
  }
  return res;
}

function resolveRepoHead(repo) {
  const result = spawnSync(
    "git",
    ["ls-remote", "--symref", `https://github.com/${repo}.git`, "HEAD"],
    {
      encoding: "utf-8",
      stdio: ["ignore", "pipe", "pipe"],
    },
  );
  if (result.status !== 0) {

View on GitHub (pinned to 0ec725320d)