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

  1. Inspect the real upstream status: capture `resp.status` and `await resp.text()` into the thrown error message before checking `resp.ok`.
  2. 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).
  3. Re-authenticate the affected user in the Basecamp integration settings to refresh `account.href` and tokens.
  4. Confirm the `user_agent` constant matches a value Basecamp's API accepts.
  5. 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

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


AI-assisted analysis of calcom/cal.diy@176037d0af (2026-08-12). Data as JSON: /api/errors/982819d42295fe03. Report an issue: GitHub.