From 59c13397395e649a027bfdc56b9b508334e3d37b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:10:29 +0000 Subject: [PATCH 1/2] fix(app-shell): the marketplace load panel reads the proxy's unreachable 502 as unreachable The runtime's marketplace proxy answers 502 MARKETPLACE_PROXY_FAILED only when its own fetch to the catalog throws. The panel picked its class line by status alone, so that 502 read as the catalog answering with an error, and the raw transport text (fetch failed) sat in full view. The load failure now carries the stamped code: that code, or no status at all, is the unreachable class, whose raw text sits behind a More / Less disclosure reusing the refusalDetails keys. Answered failures keep answeredError and their visible server text; the online hint is unchanged. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../console/marketplace/MarketplacePage.tsx | 119 +++++++++++++++--- ...tplacePage.loadFailureCause-11688.test.tsx | 50 +++++++- 2 files changed, 149 insertions(+), 20 deletions(-) diff --git a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx index a65c3701b4b..f19da1750ab 100644 --- a/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx +++ b/ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx @@ -18,8 +18,13 @@ import { Input, Button, Skeleton, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, } from '@object-ui/components'; -import { Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2 } from 'lucide-react'; +import { + Package, Search, Store, AlertCircle, CheckCircle2, Settings, Loader2, ChevronDown, Copy, Check, +} from 'lucide-react'; import { useWorkspaceAdminStatus } from '@object-ui/auth'; import { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { useAdapter } from '@object-ui/react'; @@ -150,15 +155,23 @@ function useRelativeFormatter() { interface LoadFailure { message: string; showOnlineHint: boolean; + /** The answered status, or `undefined` when no server answered (objectui#12710). */ + status: number | undefined; + /** The answered error code `call()` stamps from the envelope, if any. */ + code: string | undefined; /** - * The answered status, or `undefined` when no server answered (objectui#12710). - * It names the failure's class on the page: "could not be reached" (a - * transport failure: a network error, a CORS refusal, an abort) against - * "answered with an error" (any HTTP status). A 502 from the runtime's proxy - * is the second class with the online hint beside it: the runtime answered, - * and what it said is that it could not reach the plane behind it. + * The failure's class on the page (objectui#12710, #25891): "could not be + * reached" against "answered with an error (HTTP n)". Unreachable is a + * transport failure (a network error, a CORS refusal, an abort: no status), + * or the runtime proxy's own `MARKETPLACE_PROXY_FAILED`, which it answers + * only when its own fetch to the catalog threw: that 502 is the runtime + * saying it reached nothing, not the catalog answering. A 502 / 503 / 504 the + * upstream itself answered is relayed with the upstream's own code, and stays + * an answer. The unreachable class's message is the raw transport text + * ("fetch failed"), so the page keeps it behind More; an answer's message is + * the server's own words and stays in full view (objectui#11688). */ - status: number | undefined; + unreachable: boolean; } /** @@ -168,14 +181,93 @@ interface LoadFailure { */ const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); +/** The code the runtime's marketplace proxy answers when its upstream fetch throws. */ +const PROXY_UNREACHABLE_CODE = 'MARKETPLACE_PROXY_FAILED'; + function toLoadFailure(e: unknown): LoadFailure { - const err = e as { message?: unknown; status?: unknown } | null | undefined; + const err = e as { message?: unknown; status?: unknown; code?: unknown } | null | undefined; const status = typeof err?.status === 'number' ? err.status : undefined; + const code = typeof err?.code === 'string' ? err.code : undefined; return { message: typeof err?.message === 'string' ? err.message : String(e), showOnlineHint: status === undefined || UNREACHABLE_STATUSES.has(status), status, + code, + unreachable: status === undefined || code === PROXY_UNREACHABLE_CODE, + }; +} + +/** + * The load failure's class line and its cause (#25891). An answer shows the + * server's own words under its class line. An unreachable failure says so in + * one plain line, and the raw transport text, written for a developer, sits + * behind More, selectable and copyable, as `RefusalMessage` keeps a refusal's + * detail (the same `refusalDetails` keys). + */ +function LoadFailureCause({ failure }: { failure: LoadFailure }) { + const { t } = useObjectTranslation(); + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const id = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(id); + }, [copied]); + + // The server's own text, as a text child: it is server-supplied data, so it + // never reaches `dangerouslySetInnerHTML`. + if (!failure.unreachable) { + return ( + <> +
+ {t('marketplace.load.answeredError', { status: failure.status })} +
+
{failure.message}
+ + ); + } + + const copy = async () => { + try { + await navigator.clipboard.writeText(failure.message); + setCopied(true); + } catch { + // Denied or unavailable: the text is on screen and selectable. + } }; + + return ( + + {t('marketplace.load.unreachable')}{' '} + + {open ? t('refusalDetails.less') : t('refusalDetails.more')} + + +
+
+            {failure.message}
+          
+ +
+
+
+ ); } /** @@ -717,14 +809,7 @@ export function MarketplacePage() {