You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.ts — renderUnavailableBadgeSvg, 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.
Context
src/api/routes.tsregisters 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 atry/catchand returns a clean, typed error response instead of letting an exception escape as Hono's bare, unstructured 500 (noapp.onErroris registered anywhere in this app):GET /v1/public/github/repos/:owner/:repo/stats(routes.ts, callsfetchPublicRepoStats) — wrapped in try/catch, returns{ error: "github_repo_stats_unavailable" }with status 503 on failure.GET /v1/public/repos/:owner/:repo/quality(routes.ts, callsloadPublicRepoQualityMetrics) — 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.svgandGET /v1/public/repos/:owner/:repo/badge.json(routes.ts, both callloadPublicRepoBadge) have no error handling at all:loadPublicRepoBadge(routes.ts) is not fail-safe — it callsgetRepository(a D1 read),resolveRepositorySettings(a D1 read plus, per its own doc comment, "occasionally a cold-cache GitHub fetch" for the manifest), andlistPullRequests(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 thequality === nullcase.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 clean503— is already applied at/v1/orb/relay/registerand/v1/orb/relay/pull(see the#4995/#orb-broker-500comments directly abovevalidateOrbRelayEnrollment(...).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.svgandGET /v1/public/repos/:owner/:repo/badge.jsonmust never let an exception fromloadPublicRepoBadge(or any function it calls) escape as an unhandled 500.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/qualityalready makes for its own null-vs-error split.Cache-Controlsemantics already in place for the success/404 branches. On the new 503 branch, use a short cache (mirror the existing 404 branch'spublic, max-age=300— do not let a transient 503 get cached for the longmax-age=600, stale-while-revalidate=86400duration).loadPublicRepoBadgeitself or any function it calls — this is a route-level fix (catch the loader call in the route handler), matching how/v1/orb/relay/registerfixed the identical class of bug without modifyingvalidateOrbRelayEnrollment/registerValidatedOrbRelay.Deliverables
badge.svgroute wraps itsloadPublicRepoBadgecall so a thrown error returnsrenderUnavailableBadgeSvg()with status 503 andCache-Control: public, max-age=300.badge.jsonroute wraps itsloadPublicRepoBadgecall so a thrown error returns the existing unavailable-badge JSON shape with status 503 andCache-Control: public, max-age=300.quality === null→ 404 behavior for both routes is unchanged.Test Coverage Requirements
src/api/routes.tsis insrc/**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 totest/integration/api.test.ts(which already coversbadge.svg/badge.json's not-opted-in/private/uninstalled/unknown cases):getRepository,resolveRepositorySettings, orlistPullRequeststhrows (stub the D1 binding or the underlying repository function to reject) and assertbadge.svgresponds 503 with the unavailable SVG body andCache-Control: public, max-age=300.badge.json, asserting the 503 status and the{ schemaVersion: 1, ..., message: "unavailable", color: "#9e9e9e" }body shape.quality === nullpath 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), andloadPublicRepoBadge.src/api/badge.ts—renderUnavailableBadgeSvg,PUBLIC_BADGE_LABELshape used by the existing 404 branch.#4995/#orb-broker-500comments abovevalidateOrbRelayEnrollment(...).catch(dbBrokerError)at/v1/orb/relay/registerand/v1/orb/relay/pull.orb_relay_drain_http_500, 872 Sentry events).test/integration/api.test.ts(badge.svg/badge.json coverage today).