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
17 changes: 17 additions & 0 deletions backend/src/api/reputation/router.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { Router } from 'express';
import { assertBittensorDataAvailable } from '../../blockchain/bittensor/client.js';
import { buildReputationSignals, getDemoLeaderboard } from '../../services/reputation/reputationService.js';
import { getWalletSnapshot } from '../../blockchain/bittensor/client.js';

export const reputationRouter = Router();

<<<<<<< HEAD
reputationRouter.get('/signals', (_request, response, next) => {
try {
assertBittensorDataAvailable();
response.json(buildReputationSignals());
} catch (error) {
next(error);
}
});

reputationRouter.get('/leaderboard', (_request, response, next) => {
try {
assertBittensorDataAvailable();
response.json(getDemoLeaderboard());
=======
reputationRouter.get('/signals', (_request, response) => {
response.json(
buildReputationSignals({
Expand All @@ -20,6 +36,7 @@ reputationRouter.get('/leaderboard', async (_request, response, next) => {
try {
void getWalletSnapshot;
response.json(await getDemoLeaderboard());
>>>>>>> 3d30bfe7b19a81cc60c75aebff2b33d23861efe5
} catch (error) {
next(error);
}
Expand Down
20 changes: 20 additions & 0 deletions backend/src/blockchain/bittensor/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<<<<<<< HEAD
import { BittensorDataUnavailableError } from './errors.js';
=======
import type { ProfileCacheMetadata, SubnetParticipation, SubnetRole } from '@tao-passport/shared-types';
import { createTtlCache } from '../../utils/ttlCache.js';

type RawSubnetParticipation = Omit<SubnetParticipation, 'recentActivity'>;
>>>>>>> 3d30bfe7b19a81cc60c75aebff2b33d23861efe5

export type ChainWalletSnapshot = {
walletAddress: string;
Expand All @@ -13,6 +17,21 @@ export type ChainWalletSnapshot = {
subnetParticipation: RawSubnetParticipation[];
};

<<<<<<< HEAD
export function assertBittensorDataAvailable() {
if (process.env.TAO_PASSPORT_FORCE_BITTENSOR_UNAVAILABLE === 'true') {
throw new BittensorDataUnavailableError(
'Bittensor data is temporarily unavailable. Please retry after the chain adapter reconnects.',
);
}
}

export async function getWalletSnapshot(walletAddress: string): Promise<ChainWalletSnapshot> {
assertBittensorDataAvailable();

return {
walletAddress,
=======
type SubnetMetadata = {
subnetId: number;
recentActivity: string;
Expand All @@ -31,6 +50,7 @@ const sampleWalletAddress = '5FAbc123TAOPassportDemoWalletAddress999999999999';

const walletFixtures: Record<string, Omit<ChainWalletSnapshot, 'walletAddress'>> = {
[sampleWalletAddress]: {
>>>>>>> 3d30bfe7b19a81cc60c75aebff2b33d23861efe5
validatorScore: 92,
minerScore: 76,
communityScore: 87,
Expand Down
14 changes: 14 additions & 0 deletions backend/src/blockchain/bittensor/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export class BittensorDataUnavailableError extends Error {
readonly code = 'BITTENSOR_DATA_UNAVAILABLE';
readonly retryable = true;
readonly source = 'bittensor';

constructor(message = 'Bittensor data is temporarily unavailable.') {
super(message);
this.name = 'BittensorDataUnavailableError';
}
}

export function isBittensorDataUnavailableError(error: unknown): error is BittensorDataUnavailableError {
return error instanceof BittensorDataUnavailableError;
}
9 changes: 8 additions & 1 deletion backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { achievementsRouter } from './api/achievements/router.js';
import { healthRouter } from './api/health/router.js';
import { passportRouter } from './api/passport/router.js';
import { reputationRouter } from './api/reputation/router.js';
import { isBittensorDataUnavailableError } from './blockchain/bittensor/errors.js';
import { sendApiError } from './utils/http.js';

dotenv.config();

Expand All @@ -25,7 +27,12 @@ app.use('/api/reputation', reputationRouter);

app.use((error: Error, _request: express.Request, response: express.Response, next: express.NextFunction) => {
void next;
response.status(500).json({ error: error.message });

if (isBittensorDataUnavailableError(error)) {
return sendApiError(response, 503, error.code, error.message, error.retryable, error.source);
}

return sendApiError(response, 500, 'INTERNAL_SERVER_ERROR', error.message, false, 'api');
});

app.listen(port, () => {
Expand Down
23 changes: 22 additions & 1 deletion backend/src/utils/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import type { Response } from 'express';
import type { ApiErrorCode, ApiErrorPayload } from '@tao-passport/shared-types';

export function badRequest(response: Response, message: string) {
return response.status(400).json({ error: message });
return sendApiError(response, 400, 'INVALID_WALLET_ADDRESS', message, false, 'validation');
}

export function sendApiError(
response: Response,
status: number,
code: ApiErrorCode,
message: string,
retryable: boolean,
source: ApiErrorPayload['error']['source'] = 'api',
) {
const payload: ApiErrorPayload = {
error: {
code,
message,
retryable,
source,
},
};

return response.status(status).json(payload);
}
28 changes: 28 additions & 0 deletions backend/tests/bittensorErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { BittensorDataUnavailableError } from '../src/blockchain/bittensor/errors.js';
import { getWalletSnapshot } from '../src/blockchain/bittensor/client.js';

test('throws a retryable typed error when Bittensor data is unavailable', async () => {
const previousValue = process.env.TAO_PASSPORT_FORCE_BITTENSOR_UNAVAILABLE;
process.env.TAO_PASSPORT_FORCE_BITTENSOR_UNAVAILABLE = 'true';

try {
await assert.rejects(
() => getWalletSnapshot('5FAbc123TAOPassportDemoWalletAddress999999999999'),
(error: unknown) => {
assert.ok(error instanceof BittensorDataUnavailableError);
assert.equal(error.code, 'BITTENSOR_DATA_UNAVAILABLE');
assert.equal(error.retryable, true);
assert.equal(error.source, 'bittensor');
return true;
},
);
} finally {
if (previousValue === undefined) {
delete process.env.TAO_PASSPORT_FORCE_BITTENSOR_UNAVAILABLE;
} else {
process.env.TAO_PASSPORT_FORCE_BITTENSOR_UNAVAILABLE = previousValue;
}
}
});
38 changes: 31 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,54 @@ import { LeaderboardPage } from './pages/Leaderboard/LeaderboardPage';
import { HomePage } from './pages/Home/HomePage';
import { PassportPage } from './pages/Passport/PassportPage';
import { SearchPage } from './pages/Search/SearchPage';
import { passportApi } from './services/api';
import { ApiRequestError, passportApi } from './services/api';
import type { PageKey } from './types/navigation';

export default function App() {
const [activePage, setActivePage] = useState<PageKey>('home');
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [leaderboardError, setLeaderboardError] = useState<ApiRequestError | Error | null>(null);
const passportState = useSamplePassport();

useEffect(() => {
passportApi.getLeaderboard().then(setLeaderboard).catch(() => setLeaderboard([]));
passportApi
.getLeaderboard()
.then((entries) => {
setLeaderboard(entries);
setLeaderboardError(null);
})
.catch((error: Error) => {
setLeaderboard([]);
setLeaderboardError(error);
});
}, []);

return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,#1d4ed8_0,transparent_32rem),#050816]">
<NavBar activePage={activePage} onNavigate={setActivePage} />
{passportState.error && (
<div className="mx-auto mt-6 max-w-6xl rounded-2xl border border-amber-400/30 bg-amber-500/10 px-5 py-3 text-amber-100">
Backend unavailable: {passportState.error}. Start the API with <code>npm run dev --workspace backend</code>.
</div>
)}
{passportState.error && <ApiErrorBanner error={passportState.error} context="passport data" />}
{leaderboardError && <ApiErrorBanner error={leaderboardError} context="leaderboard data" />}
{activePage === 'home' && <HomePage passport={passportState.data} onOpenPassport={() => setActivePage('passport')} />}
{activePage === 'passport' && <PassportPage passport={passportState.data} />}
{activePage === 'leaderboard' && <LeaderboardPage entries={leaderboard} />}
{activePage === 'search' && <SearchPage />}
</div>
);
}

function ApiErrorBanner({ error, context }: { error: ApiRequestError | Error; context: string }) {
const isBittensorUnavailable = error instanceof ApiRequestError && error.code === 'BITTENSOR_DATA_UNAVAILABLE';
const title = isBittensorUnavailable ? 'Bittensor data is temporarily unavailable' : `Unable to load ${context}`;
const action = isBittensorUnavailable
? 'Cached or demo passport data will remain visible when available. Retry after the chain adapter reconnects.'
: 'Start the API with npm run dev --workspace backend and retry the request.';

return (
<div className="mx-auto mt-6 max-w-6xl rounded-2xl border border-amber-400/30 bg-amber-500/10 px-5 py-4 text-amber-100">
<p className="font-semibold">{title}</p>
<p className="mt-1 text-sm text-amber-100/85">
{error.message} {action}
</p>
</div>
);
}
12 changes: 9 additions & 3 deletions frontend/src/hooks/usePassport.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import type { TaoPassport } from '@tao-passport/shared-types';
import { passportApi } from '../services/api';
import { ApiRequestError, passportApi } from '../services/api';

type PassportState = {
data: TaoPassport | null;
error: string | null;
error: ApiRequestError | Error | null;
loading: boolean;
};

Expand All @@ -15,7 +15,13 @@ export function useSamplePassport(): PassportState {
passportApi
.getSamplePassport()
.then((data) => setState({ data, error: null, loading: false }))
.catch((error: Error) => setState({ data: null, error: error.message, loading: false }));
.catch((error: Error) =>
setState((current) => ({
data: current.data,
error,
loading: false,
})),
);
}, []);

return state;
Expand Down
54 changes: 52 additions & 2 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,63 @@
<<<<<<< HEAD
import type { ApiErrorCode, ApiErrorPayload, LeaderboardEntry, TaoPassport } from '@tao-passport/shared-types';
=======
import type { LeaderboardEntry, TaoPassport } from '@tao-passport/shared-types';
import { normalizeWalletAddress } from './wallet';
>>>>>>> 3d30bfe7b19a81cc60c75aebff2b33d23861efe5

const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000';

export class ApiRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: ApiErrorCode,
readonly retryable: boolean,
readonly source: ApiErrorPayload['error']['source'] = 'api',
) {
super(message);
this.name = 'ApiRequestError';
}
}

function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
if (!value || typeof value !== 'object' || !('error' in value)) {
return false;
}

const error = (value as { error: unknown }).error;
return Boolean(error && typeof error === 'object' && 'code' in error && 'message' in error);
}

async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_URL}${path}`);
let response: Response;

try {
response = await fetch(`${API_URL}${path}`);
} catch {
throw new ApiRequestError('TAO Passport API is unavailable. Check that the backend service is running.', 0, 'INTERNAL_SERVER_ERROR', true, 'api');
}

if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
let payload: unknown;

try {
payload = await response.json();
} catch {
payload = null;
}

if (isApiErrorPayload(payload)) {
throw new ApiRequestError(
payload.error.message,
response.status,
payload.error.code,
payload.error.retryable,
payload.error.source,
);
}

throw new ApiRequestError(`API request failed: ${response.status}`, response.status, 'INTERNAL_SERVER_ERROR', false, 'api');
}

return response.json() as Promise<T>;
Expand Down
13 changes: 13 additions & 0 deletions packages/shared-types/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type ApiErrorCode =
| 'BITTENSOR_DATA_UNAVAILABLE'
| 'INVALID_WALLET_ADDRESS'
| 'INTERNAL_SERVER_ERROR';

export type ApiErrorPayload = {
error: {
code: ApiErrorCode;
message: string;
retryable: boolean;
source?: 'bittensor' | 'api' | 'validation';
};
};
1 change: 1 addition & 0 deletions packages/shared-types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { ApiErrorCode, ApiErrorPayload } from './api.js';
export type { Achievement, AchievementCategory } from './achievement.js';
export type {
GitTensorActivity,
Expand Down
Loading