fix: restore package API error handling after merge

Agent-Logs-Url: https://github.com/openclaw/clawhub/sessions/f3fe5fb1-6a7a-4223-8366-6a78c9d9309c

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-13 18:20:42 +00:00 committed by GitHub
parent 8417ac3b52
commit 4d1a08b0e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 128 additions and 113 deletions

View File

@ -117,7 +117,7 @@ describe("Header", () => {
render(<Header />);
expect(screen.getByText("Theme")).toBeTruthy();
expect(screen.getByLabelText("Theme family")).toBeTruthy();
expect(screen.getByRole("button", { name: "Claw" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Hub" })).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);

View File

@ -414,9 +414,7 @@ describe("SkillDetailPage", () => {
expect(
(
await screen.findAllByText(
/free to use, modify, and redistribute\. no attribution required\./i,
)
await screen.findAllByText(/MIT-0/i)
).length,
).toBeGreaterThan(0);
expect(

View File

@ -1,7 +1,6 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";

View File

@ -277,51 +277,32 @@ export async function fetchPluginCatalog(params: {
executesCode?: boolean;
limit?: number;
}): Promise<PluginCatalogResult> {
try {
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
const browseResponse = response as PackageCatalogBrowseResponse;
if (params.family) {
const response = await fetchPackages({
q: params.q,
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
const browseResponse = response as PackageCatalogBrowseResponse;
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
};
}
if (params.q?.trim()) {
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
url.searchParams.set("q", params.q.trim());
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
@ -329,53 +310,57 @@ export async function fetchPluginCatalog(params: {
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const result = await fetchJson<PluginCatalogResult>(url);
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
} catch {
// Return empty result on API error to prevent SSR crashes
return { items: [], nextCursor: null };
}
const url = await packageApiUrl(ApiRoutes.plugins);
if (params.cursor) url.searchParams.set("cursor", params.cursor);
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
const result = await fetchJson<PluginCatalogResult>(url);
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
};
}
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404 || !response.ok) {
return { package: null, owner: null };
}
return (await response.json()) as PackageDetailResponse;
} catch {
// Return empty result on API error to prevent SSR crashes
return { package: null, owner: null };
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
if (response.status === 404) {
return {
package: null,
owner: null,
} satisfies PackageDetailResponse;
}
if (!response.ok) throw await createPackageApiError(response);
return (await response.json()) as PackageDetailResponse;
}
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
try {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
);
return await fetchJson<PackageVersionDetail>(url);
} catch {
// Return null on API error to prevent SSR crashes
return null;
}
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
);
return await fetchJson<PackageVersionDetail>(url);
}
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
try {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
return null;
} catch {
// Return null on API error to prevent SSR crashes
return null;
}
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
if (response.status === 403 || response.status === 423 || response.status === 404) return null;
throw await createPackageApiError(response);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
const PREFERENCES_KEY = "clawhub-preferences";

View File

@ -3,10 +3,7 @@ import { useAction, useQuery } from "convex/react";
import {
ArrowRight,
Code2,
Download,
Flame,
Ghost,
Package,
Search,
Sparkles,
Star,
@ -21,7 +18,6 @@ import { SkillListItem } from "../components/SkillListItem";
import { SkillStatsTripletLine } from "../components/SkillStats";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { UserBadge } from "../components/UserBadge";
import { convexHttp } from "../convex/client";

View File

@ -14,6 +14,7 @@ import {
fetchPackageReadme,
fetchPackageVersion,
getPackageDownloadPath,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../lib/packageApi";
@ -35,7 +36,6 @@ type PluginDetailLoaderData = {
export const Route = createFileRoute("/plugins/$name")({
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
// All fetch functions now handle errors internally and return null/empty on failure
const requestedName = params.name;
const candidateNames = requestedName.includes("/")
? [requestedName]
@ -45,7 +45,23 @@ export const Route = createFileRoute("/plugins/$name")({
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
const candidateDetail = await fetchPackageDetail(candidateName);
let candidateDetail: PackageDetailResponse;
try {
candidateDetail = await fetchPackageDetail(candidateName);
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
detail: { package: null, owner: null },
version: null,
readme: null,
rateLimited: {
scope: 'detail',
retryAfterSeconds: error.retryAfterSeconds,
},
};
}
throw error;
}
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
@ -58,15 +74,28 @@ export const Route = createFileRoute("/plugins/$name")({
return { detail, version: null, readme: null, rateLimited: null };
}
// Fetch readme and version in parallel - functions handle errors internally
const [version, readme] = await Promise.all([
detail.package.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
: Promise.resolve(null),
fetchPackageReadme(resolvedName),
]);
let metadataRateLimited: PluginDetailRateLimitState = null;
const readmePromise = fetchPackageReadme(resolvedName).catch((error: unknown) => {
if (!isRateLimitedPackageApiError(error)) throw error;
metadataRateLimited ??= {
scope: 'metadata',
retryAfterSeconds: error.retryAfterSeconds,
};
return null;
});
const versionPromise = detail.package.latestVersion
? fetchPackageVersion(resolvedName, detail.package.latestVersion).catch((error: unknown) => {
if (!isRateLimitedPackageApiError(error)) throw error;
metadataRateLimited ??= {
scope: 'metadata',
retryAfterSeconds: error.retryAfterSeconds,
};
return null;
})
: Promise.resolve(null);
const [version, readme] = await Promise.all([versionPromise, readmePromise]);
return { detail, version, readme, rateLimited: null };
return { detail, version, readme, rateLimited: metadataRateLimited };
},
head: ({ params, loaderData }) => ({
meta: [

View File

@ -6,6 +6,7 @@ import { PluginListItem } from "../../components/PluginListItem";
import { Button } from "../../components/ui/button";
import {
fetchPluginCatalog,
isRateLimitedPackageApiError,
type PackageListItem,
} from "../../lib/packageApi";
@ -55,24 +56,31 @@ export const Route = createFileRoute("/plugins/")({
}),
loaderDeps: ({ search }) => search,
loader: async ({ deps }): Promise<PluginsLoaderData> => {
// fetchPluginCatalog now handles errors internally and returns empty results
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
try {
const data = await fetchPluginCatalog({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
});
const items = data?.items ?? [];
return {
items,
nextCursor: data?.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
apiError: items.length === 0 && !deps.q && !deps.family && !deps.verified && !deps.executesCode,
};
return {
items: data.items,
nextCursor: data.nextCursor,
rateLimited: false,
retryAfterSeconds: null,
};
} catch (error) {
if (!isRateLimitedPackageApiError(error)) throw error;
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: error.retryAfterSeconds,
};
}
},
component: PluginsIndex,
});