Add fetch timeout/retry, friendly error messages, and VAT rate constant

- fetchWithTimeout (10s) + fetchWithRetry (3 attempts, exponential backoff)
- DataError class with type classification (config/network/auth/timeout/unknown)
- User-friendly error messages in EN/AR instead of raw error strings
- Extract VAT_RATE constant (was hardcoded 1.15)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-25 18:08:24 +03:00
parent ed29e7c22c
commit 8934ba1e51
5 changed files with 103 additions and 26 deletions
+70 -21
View File
@@ -14,26 +14,70 @@ import type {
UmrahData,
NocoDBDistrict,
NocoDBMuseum,
NocoDBDailyStat
NocoDBDailyStat,
DataErrorType
} from '../types';
import { DataError } from '../types';
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
const FETCH_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const VAT_RATE = 1.15;
// Table IDs discovered dynamically from NocoDB meta API
let discoveredTables: Record<string, string> | null = null;
// ============================================
// Fetch Helpers (timeout + retry)
// ============================================
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
return res;
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function fetchWithRetry(url: string, options: RequestInit = {}, retries: number = MAX_RETRIES): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < retries; attempt++) {
try {
const res = await fetchWithTimeout(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
lastError = err as Error;
if (attempt < retries - 1) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.warn(`Fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError;
}
async function discoverTableIds(): Promise<Record<string, string>> {
if (discoveredTables) return discoveredTables;
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
const res = await fetch(
const res = await fetchWithRetry(
`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
if (!res.ok) throw new Error(`Failed to discover tables: HTTP ${res.status}`);
const json = await res.json();
const tables: Record<string, string> = {};
@@ -71,8 +115,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
return umrahData;
}
const url = `${NOCODB_URL}/api/v2/tables/${tables['PilgrimStats']}/records?limit=50`;
const res = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
const json = await res.json();
const records = json.list || [];
@@ -165,15 +208,13 @@ export function clearCache(): void {
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = [];
let offset = 0;
while (true) {
const response = await fetch(
const response = await fetchWithRetry(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
const records: T[] = json.list || [];
allRecords = allRecords.concat(records);
@@ -226,7 +267,7 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
// GrossRevenue = including VAT, NetRevenue = excluding VAT
const grossRevenue = row.GrossRevenue || 0;
const netRevenue = row.NetRevenue || (grossRevenue / 1.15);
const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE);
return {
date: date,
@@ -247,6 +288,19 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
return data;
}
// ============================================
// Error Classification
// ============================================
function classifyError(err: Error): DataErrorType {
const msg = err.message.toLowerCase();
if (msg.includes('not configured')) return 'config';
if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout';
if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('unauthorized')) return 'auth';
if (msg.includes('failed to fetch') || msg.includes('network') || msg.includes('econnrefused') || msg.includes('err_connection')) return 'network';
return 'unknown';
}
// ============================================
// Main Data Fetcher (with offline fallback)
// ============================================
@@ -254,34 +308,29 @@ async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
// Try cache
const cached = loadFromCache();
if (cached) {
console.warn('NocoDB not configured, using cached data');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
throw new Error('NocoDB not configured and no cached data available. Set VITE_NOCODB_URL, VITE_NOCODB_TOKEN, and VITE_NOCODB_BASE_ID in .env.local');
throw new DataError('NocoDB not configured', 'config');
}
try {
// Try to fetch fresh data
const data = await fetchFromNocoDB();
// Save to cache on success
saveToCache(data);
return { data, fromCache: false };
} catch (err) {
console.error('NocoDB fetch failed:', (err as Error).message);
// Try to load from cache
const cached = loadFromCache();
if (cached) {
console.warn(`Using cached data from ${new Date(cached.timestamp).toLocaleString()} (offline mode)`);
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
throw new Error(`Database unavailable and no cached data: ${(err as Error).message}`);
const errorType = classifyError(err as Error);
throw new DataError((err as Error).message, errorType);
}
}