Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ NEXT_PUBLIC_STELLAR_NETWORK=PUBLIC
NEXT_PUBLIC_HORIZON_URL=https://horizon.stellar.org
NEXT_PUBLIC_SOROBAN_RPC_URL=
NEXT_PUBLIC_CONTRACT_ID=
NEXT_PUBLIC_API_REQUEST_TIMEOUT_MS=15000

# --- Deploy / ops (server or local shell ONLY — never NEXT_PUBLIC_) ---
# Populate these in your shell or a gitignored `.env.local`, never in git.
Expand Down
36 changes: 36 additions & 0 deletions docs/frontend-api-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Bounty discovery API timeout and Retry

The bounty-discovery GET `/api/tasks` and the wallet submission-history GET
`/api/my-submissions` requests have a **15-second** deadline by default,
covering both the network request and its JSON parsing. Override the
frontend deadline at **build time** with `NEXT_PUBLIC_API_REQUEST_TIMEOUT_MS`
(e.g. `NEXT_PUBLIC_API_REQUEST_TIMEOUT_MS=10000`). Values must be whole
milliseconds from 1 to 120000; missing or invalid values use the safe default.

`frontend/src/lib/request-timeout.ts` exports a reusable
`withRequestTimeout(operation, { signal, timeoutMs })` helper. On deadline, it
rejects with `ApiRequestTimeoutError` and aborts the transport; on caller
cancellation (filter change or component unmount), it raises `AbortError` and
does not display an error. A timed-out operation cannot subsequently resolve
its wrapper, **even when a fetch adapter ignores abort**. The bounded operation
includes parsing, not just obtaining a response header.

The `useBountyDiscovery` hook retries only when the user clicks **Retry
loading bounties**, preserving the current filters and page. The submission
history page likewise offers **Retry loading history**, retaining the current
connected wallet address. It does not
silently retry POST or other potentially non-idempotent requests. A loading
status is displayed until success or an error, and failed requests show an
accessible message with the Retry button. Query changes cancel the previous
request to stop stale responses from replacing new results.

To verify locally, run from the `frontend/` directory:

```bash
pnpm exec vitest run src/lib/request-timeout.test.ts src/components/BountyList.test.tsx
```

The test suite exercises a never-resolving request, stalled response parsing,
caller cancellation, timeouts that ignore abort signals, late results, and
success on a manual retry. This pattern can be reused by other **read-only**
API screens that need bounded loading states.
4 changes: 4 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ NEXT_PUBLIC_CONTRACT_ID=
# Optional app environment label for diagnostics (not a secret).
# NODE_ENV is set automatically by Next.js (development | production | test).

# Bounded GET request deadline for bounty discovery and submission history, in ms.
# Default 15000; allowed 1..120000. This is a public, build-time setting.
NEXT_PUBLIC_API_REQUEST_TIMEOUT_MS=15000

# Set to true to auto-seed the in-memory demo data when the local task API is first read.
# This is ignored in production.
NEXT_PUBLIC_SEED_DEMO_DATA=false
3 changes: 2 additions & 1 deletion frontend/src/app/(dashboard)/bounties/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BountyList } from "@/components/BountyList";
import { useBountyDiscovery } from "@/hooks/useBountyDiscovery";

export default function BountiesPage() {
const { tasks, pagination, isLoading, error, filters, setFilters, resetFilters, setPage } =
const { tasks, pagination, isLoading, error, filters, setFilters, resetFilters, setPage, retry } =
useBountyDiscovery();

return (
Expand All @@ -21,6 +21,7 @@ export default function BountiesPage() {
isLoading={isLoading}
error={error}
onPageChange={setPage}
onRetry={retry}
/>
</div>
);
Expand Down
32 changes: 22 additions & 10 deletions frontend/src/app/(dashboard)/my-submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge";
import { ChevronDown, Clock, FileText, Inbox, Link2, MessageSquare } from "lucide-react";
import { getPublicKey } from "@/hooks/stellar-wallets-kit";
import { withRequestTimeout } from "@/lib/request-timeout";
import type { SubmissionStatus, TaskStatus } from "@/types/task-workflow";

interface SubmissionHistoryRow {
Expand Down Expand Up @@ -226,6 +227,7 @@ export default function MySubmissionsPage() {
const [submissions, setSubmissions] = useState<SubmissionHistoryRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const [walletAddress, setWalletAddress] = useState<string | null>(null);
const [statusTab, setStatusTab] = useState<StatusTab>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
Expand Down Expand Up @@ -265,15 +267,16 @@ export default function MySubmissionsPage() {

try {
const params = new URLSearchParams({ contributor: walletAddress! });
const response = await fetch(`/api/my-submissions?${params.toString()}`, {
signal: controller.signal,
});
const body = await response.json();

if (!response.ok || !body.ok) {
throw new Error(body.error ?? "Failed to load submission history.");
}
const body = await withRequestTimeout(async (signal) => {
const response = await fetch(`/api/my-submissions?${params.toString()}`, { signal });
const result = await response.json();
if (!response.ok || !result.ok) {
throw new Error(result.error ?? "Failed to load submission history.");
}
return result;
}, { signal: controller.signal });

if (controller.signal.aborted) return;
setSubmissions(body.submissions);
} catch (err) {
if (controller.signal.aborted) return;
Expand All @@ -288,7 +291,7 @@ export default function MySubmissionsPage() {
run();

return () => controller.abort();
}, [walletAddress]);
}, [walletAddress, retryToken]);

const counts = useMemo(() => {
const byStatus: Record<StatusTab, number> = {
Expand Down Expand Up @@ -341,7 +344,16 @@ export default function MySubmissionsPage() {

{walletAddress && !isLoading && error && (
<Card className="bg-[#0A0B0F]/40 backdrop-blur-xl border border-white/10 shadow-2xl">
<CardContent className="py-10 text-center text-sm text-red-300">{error}</CardContent>
<CardContent className="flex flex-col items-center gap-3 py-10 text-center text-sm text-red-300">
<p role="alert">{error}</p>
<button
type="button"
onClick={() => setRetryToken((current) => current + 1)}
className="rounded-lg border border-white/20 px-4 py-2 text-white hover:bg-white/10"
>
Retry loading history
</button>
</CardContent>
</Card>
)}

Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/BountyList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { BountyList } from "./BountyList";

const defaults = {
tasks: [],
pagination: { page: 1, pageSize: 10, total: 0, totalPages: 1 },
onPageChange: vi.fn(),
onRetry: vi.fn(),
};

describe("BountyList timeout and retry feedback", () => {
it("exposes a visible, accessible retry action after a request error", () => {
const html = renderToStaticMarkup(
<BountyList {...defaults} isLoading={false} error="Request timed out. Please try again." />,
);
expect(html).toContain('role="alert"');
expect(html).toContain("Request timed out");
expect(html).toContain("Retry loading bounties");
expect(html).toContain("<button");
});

it("shows a named loading status instead of a blank empty grid", () => {
const html = renderToStaticMarkup(
<BountyList {...defaults} isLoading={true} error={null} />,
);
expect(html).toContain('role="status"');
expect(html).toContain("Loading bounties");
});

it("does not offer retry for a legitimate empty search result", () => {
const html = renderToStaticMarkup(
<BountyList {...defaults} isLoading={false} error={null} />,
);
expect(html).toContain("No bounties found");
expect(html).not.toContain("Retry loading bounties");
});
});
16 changes: 14 additions & 2 deletions frontend/src/components/BountyList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,26 @@ interface BountyListProps {
isLoading: boolean;
error: string | null;
onPageChange: (page: number) => void;
onRetry: () => void;
}

export function BountyList({ tasks, pagination, isLoading, error, onPageChange }: BountyListProps) {
export function BountyList({ tasks, pagination, isLoading, error, onPageChange, onRetry }: BountyListProps) {
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<h3 className="text-xl font-semibold text-white mb-2">Couldn&apos;t load bounties</h3>
<p className="text-muted-foreground">{error}</p>
<p className="text-muted-foreground" role="alert">{error}</p>
<Button type="button" variant="outline" className="mt-4" onClick={onRetry}>
Retry loading bounties
</Button>
</div>
);
}

if (isLoading && tasks.length === 0) {
return (
<div role="status" aria-live="polite" className="py-20 text-center text-muted-foreground">
Loading bounties…
</div>
);
}
Expand Down
26 changes: 17 additions & 9 deletions frontend/src/hooks/useBountyDiscovery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { withRequestTimeout } from "@/lib/request-timeout";
import type {
TaskDifficulty,
TaskRecord,
Expand Down Expand Up @@ -51,6 +52,8 @@ export interface UseBountyDiscoveryReturn {
setFilters: (partial: Partial<BountyFilters>) => void;
resetFilters: () => void;
setPage: (page: number) => void;
/** Re-run the current filter/page query after an error or timeout. */
retry: () => void;
}

const SEARCH_DEBOUNCE_MS = 300;
Expand Down Expand Up @@ -83,6 +86,7 @@ export function useBountyDiscovery(): UseBountyDiscoveryReturn {
const [pagination, setPagination] = useState<BountyPagination>(DEFAULT_PAGINATION);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);

// Debounce the free-text search field only; other filters (selects, number
// inputs) apply immediately since they don't fire on every keystroke.
Expand Down Expand Up @@ -111,15 +115,16 @@ export function useBountyDiscovery(): UseBountyDiscoveryReturn {
setError(null);

try {
const response = await fetch(`/api/tasks?${queryString}`, {
signal: controller.signal,
});
const body = await response.json();

if (!response.ok || !body.ok) {
throw new Error(body.error ?? "Failed to load bounties.");
}
const body = await withRequestTimeout(async (signal) => {
const response = await fetch(`/api/tasks?${queryString}`, { signal });
const result = await response.json();
if (!response.ok || !result.ok) {
throw new Error(result.error ?? "Failed to load bounties.");
}
return result;
}, { signal: controller.signal });

if (controller.signal.aborted) return;
setTasks(body.tasks);
setPagination(body.pagination);
} catch (err) {
Expand All @@ -135,7 +140,7 @@ export function useBountyDiscovery(): UseBountyDiscoveryReturn {
run();

return () => controller.abort();
}, [queryString]);
}, [queryString, retryToken]);

const setFilters = (partial: Partial<BountyFilters>) => {
setFiltersState((prev) => ({ ...prev, ...partial }));
Expand All @@ -152,6 +157,8 @@ export function useBountyDiscovery(): UseBountyDiscoveryReturn {
setPageState(Math.max(1, Math.floor(nextPage)));
};

const retry = () => setRetryToken((current) => current + 1);

return {
tasks,
pagination,
Expand All @@ -161,5 +168,6 @@ export function useBountyDiscovery(): UseBountyDiscoveryReturn {
setFilters,
resetFilters,
setPage,
retry,
};
}
Loading