Skip to content

fix(api): badge.svg/badge.json have no error handling, unlike sibling public routes #8377

Description

@JSONbored

Context

src/api/routes.ts registers several fully public, unauthenticated GET routes that read from D1/GitHub-backed loaders. Every one of them except the README badge routes wraps its loader call in a try/catch and returns a clean, typed error response instead of letting an exception escape as Hono's bare, unstructured 500 (no app.onError is registered anywhere in this app):

  • GET /v1/public/github/repos/:owner/:repo/stats (routes.ts, calls fetchPublicRepoStats) — wrapped in try/catch, returns { error: "github_repo_stats_unavailable" } with status 503 on failure.
  • GET /v1/public/repos/:owner/:repo/quality (routes.ts, calls loadPublicRepoQualityMetrics) — wrapped in try/catch, returns { error: "public_quality_metrics_unavailable" } with status 503 on failure.
  • GET /v1/public/stats (routes.ts) — wrapped in try/catch, returns { error: "public_stats_unavailable" } with status 503 on failure.

But GET /v1/public/repos/:owner/:repo/badge.svg and GET /v1/public/repos/:owner/:repo/badge.json (routes.ts, both call loadPublicRepoBadge) have no error handling at all:

app.get("/v1/public/repos/:owner/:repo/badge.svg", async (c) => {
  const quality = await loadPublicRepoBadge(c.env, c.req.param("owner"), c.req.param("repo"));
  c.header("Content-Type", "image/svg+xml; charset=utf-8");
  if (!quality) {
    c.header("Cache-Control", "public, max-age=300");
    return c.body(renderUnavailableBadgeSvg(), 404);
  }
  c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400");
  return c.body(renderBadgeSvg(quality));
});

loadPublicRepoBadge (routes.ts) is not fail-safe — it calls getRepository (a D1 read), resolveRepositorySettings (a D1 read plus, per its own doc comment, "occasionally a cold-cache GitHub fetch" for the manifest), and listPullRequests (a D1 read), none of which are wrapped in a .catch(). Any transient failure in any of these (a D1 blip, a GitHub API timeout while resolving .loopover.yml) throws straight out of the route handler and becomes a raw, unstructured 500 instead of the graceful "unavailable" badge these routes already render for the quality === null case.

This is a real, previously-fixed bug class in this exact file, not a hypothetical: issue #4995 (orb_relay_drain_http_500, 872 Sentry events) is the historical incident, and the fix pattern — wrap the throwing DB/network call(s) in .catch(...) right at the call site and return a clean 503 — is already applied at /v1/orb/relay/register and /v1/orb/relay/pull (see the #4995/#orb-broker-500 comments directly above validateOrbRelayEnrollment(...).catch(dbBrokerError) in routes.ts). The badge routes are the one place in the public-route family where that pattern was never applied.

Because these badges are unauthenticated, aggressively cached (stale-while-revalidate=86400), and typically embedded directly in third-party READMEs (rendered by GitHub's camo proxy), an unhandled exception here is unusually visible — every repo embedding the badge would show a broken image/response instead of the existing graceful "unavailable" badge.

Requirements

  • GET /v1/public/repos/:owner/:repo/badge.svg and GET /v1/public/repos/:owner/:repo/badge.json must never let an exception from loadPublicRepoBadge (or any function it calls) escape as an unhandled 500.
  • On a genuine backend failure (loader throws), each route must return its existing "unavailable" rendering — renderUnavailableBadgeSvg() for .svg, the { schemaVersion: 1, label: PUBLIC_BADGE_LABEL, message: "unavailable", color: "#9e9e9e", cacheSeconds: ... } shape for .json — but with status 503, not 404. Reserve 404 for the existing "no such public/installed/opted-in repo" case (quality === null) so a monitoring/alerting consumer can still tell "this repo doesn't have a public badge" apart from "the backend is having a transient problem" — the exact same 404-vs-503 distinction /v1/public/repos/:owner/:repo/quality already makes for its own null-vs-error split.
  • Do not change the Cache-Control semantics already in place for the success/404 branches. On the new 503 branch, use a short cache (mirror the existing 404 branch's public, max-age=300 — do not let a transient 503 get cached for the long max-age=600, stale-while-revalidate=86400 duration).
  • Do not touch loadPublicRepoBadge itself or any function it calls — this is a route-level fix (catch the loader call in the route handler), matching how /v1/orb/relay/register fixed the identical class of bug without modifying validateOrbRelayEnrollment/registerValidatedOrbRelay.

Deliverables

  • badge.svg route wraps its loadPublicRepoBadge call so a thrown error returns renderUnavailableBadgeSvg() with status 503 and Cache-Control: public, max-age=300.
  • badge.json route wraps its loadPublicRepoBadge call so a thrown error returns the existing unavailable-badge JSON shape with status 503 and Cache-Control: public, max-age=300.
  • The existing quality === null → 404 behavior for both routes is unchanged.

Test Coverage Requirements

src/api/routes.ts is in src/** and is measured by Codecov at 99% patch coverage, branch-counted — both the new try/catch's success and failure arms need explicit tests, not just the pre-existing null/found arms. Add to test/integration/api.test.ts (which already covers badge.svg/badge.json's not-opted-in/private/uninstalled/unknown cases):

  • A case where getRepository, resolveRepositorySettings, or listPullRequests throws (stub the D1 binding or the underlying repository function to reject) and assert badge.svg responds 503 with the unavailable SVG body and Cache-Control: public, max-age=300.
  • The equivalent case for badge.json, asserting the 503 status and the { schemaVersion: 1, ..., message: "unavailable", color: "#9e9e9e" } body shape.
  • A regression test confirming the existing quality === null path still returns 404 (not 503) so the two failure modes stay distinguishable.

Expected Outcome

A transient backend failure while resolving badge data degrades gracefully to the existing "unavailable" badge (503, short cache) instead of surfacing a raw, unstructured 500 to every README/page embedding the badge — matching the error-handling contract every sibling public route (/v1/public/github/repos/:owner/:repo/stats, /v1/public/repos/:owner/:repo/quality, /v1/public/stats) and the previously-fixed /v1/orb/relay/* routes already provide.

Links & Resources

  • src/api/routes.ts — the two route handlers (app.get("/v1/public/repos/:owner/:repo/badge.svg", ...) and .../badge.json), and loadPublicRepoBadge.
  • src/api/badge.tsrenderUnavailableBadgeSvg, PUBLIC_BADGE_LABEL shape used by the existing 404 branch.
  • Precedent for this exact fix pattern in the same file: the #4995 / #orb-broker-500 comments above validateOrbRelayEnrollment(...).catch(dbBrokerError) at /v1/orb/relay/register and /v1/orb/relay/pull.
  • Historical incident this bug class caused: fix(orb): drainOrbRelay returns HTTP 500 repeatedly (872 Sentry events, escalating) #4995 (orb_relay_drain_http_500, 872 Sentry events).
  • Existing tests: test/integration/api.test.ts (badge.svg/badge.json coverage today).

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions