calcom/cal.diy · error · HttpError
Failed to fetch Basecamp projects
Error message
Failed to fetch Basecamp projects
What it means
Thrown by the Basecamp 3 projects endpoint after the upstream GET to `${account.href}/projects.json` returns a non-2xx response. The handler refreshes the OAuth access token when `credentialKey.expires_at < Date.now()`, then issues an authenticated Bearer request; any non-OK response from Basecamp collapses into this HttpError 400. The actual upstream status and body are discarded, so the message is generic.
Source
Thrown at packages/app-store/basecamp3/api/projects.ts:45
}
let credentialKey = credential.key as BasecampToken;
if (!credentialKey.account) {
return { currentProject: null, projects: [] };
}
if (credentialKey.expires_at < Date.now()) {
credentialKey = (await refreshAccessToken(credential)) as BasecampToken;
}
const url = `${credentialKey.account.href}/projects.json`;
const resp = await fetch(url, {
headers: { "User-Agent": user_agent as string, Authorization: `Bearer ${credentialKey.access_token}` },
});
if (!resp.ok) {
throw new HttpError({ statusCode: 400, message: "Failed to fetch Basecamp projects" });
}
const projects = await resp.json();
return { currentProject: credentialKey.projectId, projects };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
View on GitHub (pinned to 176037d0af)
Solutions
- Inspect the real upstream status: capture `resp.status` and `await resp.text()` into the thrown error message before checking `resp.ok`.
- Verify the Basecamp OAuth refresh flow actually persists the new `access_token`/`expires_at` to the credential before the projects call (the reassignment `credentialKey = await refreshAccessToken(credential)` passes `credential`, not `credentialKey` — confirm refresh reads the right token).
- Re-authenticate the affected user in the Basecamp integration settings to refresh `account.href` and tokens.
- Confirm the `user_agent` constant matches a value Basecamp's API accepts.
- Add a retry with backoff for transient 5xx/429 responses before surfacing the 400.
Example fix
// before
if (!resp.ok) {
throw new HttpError({ statusCode: 400, message: "Failed to fetch Basecamp projects" });
}
// after
if (!resp.ok) {
const body = await resp.text().catch(() => "");
throw new HttpError({
statusCode: resp.status === 401 || resp.status === 403 ? resp.status : 502,
message: `Failed to fetch Basecamp projects (upstream ${resp.status}): ${body.slice(0, 200)}`,
});
} Defensive patterns
Strategy: try-catch
Validate before calling
if (!credentialKey?.account?.href || !credentialKey?.access_token) {
throw new Error("Basecamp credential incomplete: account.href or access_token missing");
} Type guard
function hasBasecampAccount(k: unknown): k is { account: { href: string }; access_token: string; expires_at: number } {
return !!k && typeof k === "object" && !!(k as any).account?.href && !!(k as any).access_token;
} Try / catch
try {
const projects = await fetchBasecampProjects(credential);
} catch (e) {
if (e instanceof HttpError && e.message.includes("Basecamp projects")) {
// prompt user to reconnect Basecamp; surface upstream status if available
}
throw e;
} Prevention
- Validate the credential has account.href and access_token before the fetch.
- Persist refreshed tokens immediately so a stale token isn't reused on the next call.
- Log resp.status and body on failure instead of collapsing to a 400.
- Use a token-refresh wrapper that throws a typed error when refresh itself fails.
When it happens
Trigger: Calling GET on the Basecamp project-listing route when: (a) the access token is invalid/revoked and `refreshAccessToken` silently returned a token Basecamp rejects; (b) `credentialKey.account.href` points to the wrong Basecamp account URL; (c) the `User-Agent` header is blocked by Basecamp's API; (d) Basecamp returns 401/403/404/500 for any reason.
Common situations: Basecamp OAuth app credentials rotated without re-authenticating the user; the stored `account.href` is stale after a Basecamp account rename/migration; clock skew causing `expires_at` checks to use an already-invalid token; Basecamp rate-limiting (429) treated as a hard failure.
Related errors
- Missing 'Bearer' Authorization header.
- Invalid google OAuth credentials.
- Failed to fetch project details
- Failed to get access token
- Error refreshing dub token: ${res?.error?.message ?? respons
AI-assisted analysis of calcom/cal.diy@176037d0af (2026-08-12).
Data as JSON: /api/errors/982819d42295fe03.
Report an issue: GitHub.