File size: 10,075 Bytes
60fbe12 9cd7dfb 60fbe12 e4a1d9d 60fbe12 8b18b3a 60fbe12 b873673 8b18b3a 60fbe12 9cd7dfb 0a08115 9cd7dfb 0a08115 9cd7dfb 4d1ee67 60fbe12 9cd7dfb 0a08115 9cd7dfb 4d1ee67 60fbe12 9cd7dfb 60fbe12 8572ea1 60fbe12 4a1cc3f 9883061 4a1cc3f 9883061 4a1cc3f 60fbe12 0a08115 13bc37b 60fbe12 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | /**
* API client with comprehensive error handling, timeouts, and retry logic.
*
* VITE_API_BASE — set in frontend/.env (local) or Vercel env vars (production).
* Production example: VITE_API_BASE=https://your-space.hf.space/api
* Default (local dev): http://127.0.0.1:8000/api
*
* All calls throw on non-ok responses so callers always catch errors.
* scoreAudio derives extension from actual blob MIME type.
*
* Audio input:
* VITE_ENABLE_AUDIO_INPUT — manual override. If "false", audio is always off.
* If unset or "true", the frontend checks the backend /health endpoint at
* startup to determine if ASR is actually available.
*/
const API_BASE = (import.meta.env.VITE_API_BASE || "http://127.0.0.1:8000/api").replace(/\/$/, "");
export const AUDIO_INPUT_HINT =
import.meta.env.VITE_AUDIO_INPUT_HINT ||
"Audio mode uses Whisper for speech-to-text. Speak clearly for best results.";
// Request configuration
const REQUEST_TIMEOUT = 30000; // 30 seconds default timeout
const MAX_RETRIES = 2;
const RETRY_DELAY = 1000; // 1 second between retries
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504]; // Statuses that warrant retry
/**
* Create an AbortController with timeout
*/
function createTimeoutController(timeoutMs = REQUEST_TIMEOUT) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return { controller, timeoutId };
}
/**
* Determine if an error is retryable
*/
function isRetryableError(error, status) {
// Network errors (no connection, DNS failure, etc.)
if (error.name === 'TypeError' || error.name === 'AbortError') {
return true;
}
// Server errors that might be transient
if (status && RETRYABLE_STATUSES.includes(status)) {
return true;
}
return false;
}
/**
* Sleep helper for retry delays
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function reqWithRetry(path, options = {}, attempt = 0) {
const token = localStorage.getItem("firebaseToken");
const headers = {
"Content-Type": "application/json",
...(token ? { "Authorization": `Bearer ${token}` } : {}),
...(options.headers || {})
};
const { controller, timeoutId } = createTimeoutController(options.timeout);
try {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const error = new Error(data.detail || data.message || `API error ${res.status}`);
error.status = res.status;
error.data = data;
// Check if we should retry
if (attempt < MAX_RETRIES && isRetryableError(error, res.status)) {
console.warn(`API request failed (attempt ${attempt + 1}), retrying...`, error.message);
await sleep(RETRY_DELAY * (attempt + 1)); // Exponential backoff
return reqWithRetry(path, options, attempt + 1);
}
throw error;
}
return res.json();
} catch (error) {
clearTimeout(timeoutId);
// Handle abort/timeout specifically
if (error.name === 'AbortError') {
const timeoutError = new Error('Request timed out. Please check your connection and try again.');
timeoutError.status = 408;
timeoutError.isTimeout = true;
if (attempt < MAX_RETRIES) {
console.warn(`Request timeout (attempt ${attempt + 1}), retrying...`);
await sleep(RETRY_DELAY * (attempt + 1));
return reqWithRetry(path, options, attempt + 1);
}
throw timeoutError;
}
// Handle network errors with retry
if (attempt < MAX_RETRIES && isRetryableError(error)) {
console.warn(`Network error (attempt ${attempt + 1}), retrying...`, error.message);
await sleep(RETRY_DELAY * (attempt + 1));
return reqWithRetry(path, options, attempt + 1);
}
// Enhance network error message
if (error.name === 'TypeError' && error.message.includes('fetch')) {
error.message = 'Network error. Please check your internet connection and try again.';
error.isNetworkError = true;
}
throw error;
}
}
async function reqMultipartWithRetry(url, body, attempt = 0) {
const token = localStorage.getItem("firebaseToken");
const headers = {
...(token ? { "Authorization": `Bearer ${token}` } : {})
};
const { controller, timeoutId } = createTimeoutController(60000); // Longer timeout for file uploads
try {
const res = await fetch(url, {
method: "POST",
body,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const error = new Error(data.detail || data.message || `API error ${res.status}`);
error.status = res.status;
error.data = data;
// Check if we should retry (but be more conservative with file uploads)
if (attempt < MAX_RETRIES && isRetryableError(error, res.status) && res.status !== 413) {
console.warn(`Multipart request failed (attempt ${attempt + 1}), retrying...`, error.message);
await sleep(RETRY_DELAY * (attempt + 1));
return reqMultipartWithRetry(url, body, attempt + 1);
}
throw error;
}
return res.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
const timeoutError = new Error('Upload timed out. The file may be too large or your connection is slow.');
timeoutError.status = 408;
timeoutError.isTimeout = true;
throw timeoutError;
}
if (attempt < MAX_RETRIES && isRetryableError(error)) {
console.warn(`Multipart network error (attempt ${attempt + 1}), retrying...`, error.message);
await sleep(RETRY_DELAY * (attempt + 1));
return reqMultipartWithRetry(url, body, attempt + 1);
}
if (error.name === 'TypeError' && error.message.includes('fetch')) {
error.message = 'Network error during upload. Please check your connection and try again.';
error.isNetworkError = true;
}
throw error;
}
}
// Maintain backward compatibility with original function names
async function req(path, options = {}) {
return reqWithRetry(path, options);
}
async function reqMultipart(url, body) {
return reqMultipartWithRetry(url, body);
}
/**
* Check if an error is a network error (for UI handling)
*/
export function isNetworkError(error) {
return error?.isNetworkError === true || error?.isTimeout === true ||
error?.name === 'TypeError' || error?.name === 'AbortError';
}
/**
* Check if an error is retryable (for UI retry buttons)
*/
export function isRetryable(error) {
return isNetworkError(error) || RETRYABLE_STATUSES.includes(error?.status);
}
/** Map a MIME type string to a file extension for audio blobs. */
function audioExtFromMime(mimeType) {
if (!mimeType) return ".webm";
if (mimeType.includes("mp4")) return ".mp4";
if (mimeType.includes("ogg")) return ".ogg";
if (mimeType.includes("wav")) return ".wav";
return ".webm";
}
export const api = {
getHealth: () => req("/health"),
createSession: () => req("/session/create", { method: "POST" }),
uploadResume: (sid, file) => {
const fd = new FormData();
fd.append("session_id", sid);
fd.append("file", file);
return reqMultipart(`${API_BASE}/upload/resume`, fd);
},
parseResume: (sid) => req(`/parse/resume/${sid}`, { method: "POST" }),
// New: Parse and extract for resume autofill
parseAndExtract: (sid, file) => {
const fd = new FormData();
fd.append("session_id", sid);
fd.append("resume", file);
return reqMultipart(`${API_BASE}/parse-and-extract`, fd);
},
// New: Dynamic interview generation (company research + LLM questions)
generateDynamicInterview: (sid) => req(`/interview/generate-dynamic/${sid}`, { method: "POST" }),
setJobDescription: (payload) => req("/session/job_description", { method: "POST", body: JSON.stringify(payload) }),
setCandidateProfile: (payload) => req("/session/candidate_profile", { method: "POST", body: JSON.stringify(payload) }),
generatePlan: (sid) => req(`/interview/plan/${sid}`, { method: "POST" }),
startInterview: (sid) => req(`/session/start_interview?session_id=${encodeURIComponent(sid)}`, { method: "POST" }),
nextQuestion: (sid) => req(`/session/next_question?session_id=${encodeURIComponent(sid)}`, { method: "POST" }),
scoreText: (payload) => req("/score/text", { method: "POST", body: JSON.stringify(payload) }),
scoreAudio: (sid, qid, blob) => {
// Use the correct extension based on the actual MIME type (Safari uses audio/mp4)
const ext = audioExtFromMime(blob.type);
const filename = `answer${ext}`;
const fd = new FormData();
fd.append("file", blob, filename);
return reqMultipart(
`${API_BASE}/answer/audio?session_id=${encodeURIComponent(sid)}&question_id=${encodeURIComponent(qid)}`,
fd
);
},
sendPosture: (payload) => req("/posture/report", { method: "POST", body: JSON.stringify(payload) }),
logViolation: (payload) => req("/session/violation", { method: "POST", body: JSON.stringify(payload) }),
aggregate: (sid) => req(`/aggregate/${sid}`, { method: "POST" }),
analytics: (sid) => req(`/analytics/${sid}`, { method: "POST" }),
decision: (sid) => req(`/decision/${sid}`, { method: "POST" }),
getReport: (sid) => req(`/report/${sid}`),
getUserHistory: () => req("/user/history"),
// Session state recovery and skip support
getSessionStatus: (sid) => req(`/session/status/${encodeURIComponent(sid)}`),
skipQuestion: (sid, qid) => req(`/session/skip/${encodeURIComponent(sid)}?question_id=${encodeURIComponent(qid || "")}`, { method: "POST" }),
};
|