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:
parent
8417ac3b52
commit
4d1a08b0e0
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
const PREFERENCES_KEY = "clawhub-preferences";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user