diff --git a/.changeset/11688-marketplace-load-error-cause.md b/.changeset/11688-marketplace-load-error-cause.md new file mode 100644 index 0000000000..b969dc64de --- /dev/null +++ b/.changeset/11688-marketplace-load-error-cause.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +Browse Marketplace now says why the catalog failed to load, and no longer draws "No apps have been approved for the marketplace yet." under the error (objectui#11688). + +A refusal the server answered in plain text, such as an egress proxy's `403` "Host not in allowlist: cloud.objectos.ai", was shown as the bare status text ("Forbidden"), and every load failure carried the "check that it is online" hint. Now: + +- the error shows the server's own text: a JSON error's message, as before, or the body of a `text/plain` refusal. Any other body that is not JSON, such as an HTML error page, still shows the status text; +- the "check that it is online" hint is shown only when no server answered (a network failure, a CORS refusal) or the server answered `502`, `503` or `504`. A refusal such as a `401` or `403` shows its cause without it; +- the empty state is drawn only when the catalog loaded empty, and the "Your organization" packages and the installed count still appear when the public catalog fails to load. + +The package detail page reads its load through the same request helper, so a plain-text refusal there now shows its text too. diff --git a/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index c1256f1327..9a231e785d 100644 --- a/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -75,6 +75,38 @@ function useRelativeFormatter() { }; } +/** + * A failed catalog load as the page tells it (objectui#11688): the server's + * own words, and whether "check that it is online" is the real cause. + * + * Both hints end in that question, so they are shown only when it is the + * cause: no server answered at all (`call()` stamps `status` on every failure + * a server answered, so a failure without one never reached a server), or the + * server that answered said it could not reach the one behind it or is not + * serving. Any other answer is a refusal whose cause is the server's text, and + * an "is it online" line under a 403 sends the reader after the wrong fault. + */ +interface LoadFailure { + message: string; + showOnlineHint: boolean; +} + +/** + * The answered statuses whose cause IS reachability: 502 and 504 are a gateway + * that got no (timely) answer upstream, the runtime proxy's own + * `MARKETPLACE_PROXY_FAILED` among them, and 503 is a service that is not up. + */ +const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); + +function toLoadFailure(e: unknown): LoadFailure { + const err = e as { message?: unknown; status?: unknown } | null | undefined; + const status = typeof err?.status === 'number' ? err.status : undefined; + return { + message: typeof err?.message === 'string' ? err.message : String(e), + showOnlineHint: status === undefined || UNREACHABLE_STATUSES.has(status), + }; +} + export function MarketplacePage() { const navigate = useNavigate(); const { appName } = useParams(); @@ -94,7 +126,7 @@ export function MarketplacePage() { // no catalog is not "loading", it is done. Writing that from inside the // effect would be a second `react-hooks/set-state-in-effect` site. const [loading, setLoading] = useState(marketplaceEnabled); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [query, setQuery] = useState(''); const [category, setCategory] = useState(''); const [installed, setInstalled] = useState([]); @@ -115,21 +147,33 @@ export function MarketplacePage() { setLoading(true); setError(null); try { - const [resp, installs, org, cloudInstalled] = await Promise.all([ - listMarketplacePackages({ limit: 100 }), + const [catalog, installs, org, cloudInstalled] = await Promise.all([ + // Settled here rather than left to reject the batch. The three side + // loads each answer "nothing" on their own failure, so the catalog is + // the only one that can fail, and its failure must not discard what + // they loaded: the org section and the installed count (objectui#11688). + listMarketplacePackages({ limit: 100 }).then( + (resp) => ({ resp, failure: null }), + (e: unknown) => ({ resp: null, failure: toLoadFailure(e) }), + ), listLocalInstalls(), listOrgPackages(), listInstalledPackages(), ]); - setItems(resp.items ?? []); setInstalled(installs); setOrgItems(org.items ?? []); const ids = new Set(); for (const e of installs) ids.add(e.manifestId); for (const e of cloudInstalled.items) ids.add(e.manifestId); setInstalledIds(ids); - } catch (e: any) { - setError(e?.message ?? String(e)); + if (catalog.failure !== null) { + setError(catalog.failure); + setItems([]); + } else { + setItems(catalog.resp.items ?? []); + } + } catch (e: unknown) { + setError(toLoadFailure(e)); setItems([]); } finally { setLoading(false); @@ -303,17 +347,22 @@ export function MarketplacePage() {