different-ai/openwork · error · ApiError
plugin_fetch_failed
plugin_fetch_failed
Error message
Failed to fetch plugin data (${response.status}): ${text || url} What it means
fetchGithubJson in apps/server/src/claude-plugin-bundle.ts:107 fetches GitHub JSON APIs (used by the 'tree' and 'info' flows) with a 20s timeout and GitHub JSON Accept headers. When the response is not ok, it reads the body text (best-effort) and throws ApiError 502 with code plugin_fetch_failed, embedding the GitHub status code and either the error body or the requested URL. It indicates GitHub (or the network path to it) failed upstream, not that the caller's input was malformed.
Source
Thrown at apps/server/src/claude-plugin-bundle.ts:107
let dir: string | null = null;
let treeSegments: string[] | null = null;
if (parts[2] === "tree" && parts[3]) {
treeSegments = parts.slice(3);
ref = parts[3] ?? null;
const rest = parts.slice(4);
if (rest.length > 0) dir = rest.join("/");
}
return { owner, repo, ref, dir, treeSegments };
}
async function fetchGithubJson(url: string): Promise<unknown> {
const response = await externalFetch(url, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "openwork-server" },
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new ApiError(502, "plugin_fetch_failed", `Failed to fetch plugin data (${response.status}): ${text || url}`);
}
return response.json();
}
async function fetchGithubText(url: string): Promise<string> {
const response = await externalFetch(url, {
headers: { Accept: "text/plain", "User-Agent": "openwork-server" },
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new ApiError(502, "plugin_fetch_failed", `Failed to fetch plugin file (${response.status}): ${text || url}`);
}
return response.text();
}
type TreeEntry = { path: string; sha: string };
View on GitHub (pinned to 2b7df46e8a)
Solutions
- Check the embedded status in the message: 403/429 → rate limited; add a GITHUB_TOKEN or wait for the rate-limit window to reset.
- 404 → verify the repo exists, is public, and the ref/branch/tag referenced actually exists.
- 5xx or network text → retry after a short delay; check GitHub status (githubstatus.com) and the server's outbound network/proxy.
- If the request has an invalid pinned ref (e.g. moved branch), update the plugin source to a valid ref.
Example fix
// before
await fetchGithubJson("https://api.github.com/repos/owner/repo/git/trees/main?recursive=1"); // 403 rate-limited
// after — authenticate to raise the rate limit
await fetchGithubJson("https://api.github.com/repos/owner/repo/git/trees/main?recursive=1", {
headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
}); Defensive patterns
Strategy: retry
Validate before calling
// check inputs that map to GitHub API outcomes
if (!/^owner\/repo$/.test(shorthand)) throw new Error("invalid repo");
// optionally pre-check token/rate limit:
const rl = await fetch("https://api.github.com/rate_limit", {
headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
}).then((r) => r.json());
if (rl.resources.core.remaining === 0) throw new Error("GitHub rate limit exhausted"); Try / catch
try {
const tree = await fetchGithubJson(treeUrl);
} catch (err) {
if (err instanceof ApiError && err.code === "plugin_fetch_failed") {
if (err.message.includes("(403)") || err.message.includes("(429)")) {
// back off and retry with a GITHUB_TOKEN
} else if (err.message.includes("(404)")) {
// repo/ref does not exist; correct the plugin source
} else {
// transient 5xx/network: retry with backoff
}
} else throw err;
} Prevention
- Configure a GITHUB_TOKEN to avoid the 60 req/hr anonymous rate limit.
- Pin plugin sources to tags or commit SHAs rather than mutable branches.
- Verify the repo is public and the ref exists before installing.
- Add exponential backoff for 5xx/network failures and monitor githubstatus.com.
When it happens
Trigger: GitHub API returns 403/429 (rate limit — anonymous unauthenticated requests are limited to 60/hour per IP), 404 (repo/ref/path does not exist), 5xx from GitHub, or a network failure producing a non-ok response during plugin tree/info resolution.
Common situations: Heavy plugin installs from one server IP exhausting the unauthenticated GitHub rate limit; a plugin pinned to a deleted repo or renamed default branch; private repos (unauthenticated fetch yields 404); transient GitHub incidents.
Related errors
- github_connector_request_failed
- invalid_plugin_payload
- latest-mac.yml is missing artifact path/url.
- Failed to fetch latest-mac.yml (${response.status} ${respons
- Request timed out.
AI-assisted analysis of different-ai/openwork@2b7df46e8a (2026-09-01).
Data as JSON: /api/errors/b425a25bc0600550.
Report an issue: GitHub.