From 65e487f09380b7f144c845de0605a321eeb98563 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Mon, 8 Dec 2025 18:07:54 -0500 Subject: [PATCH 1/5] Add error handling for missing/invalid Voyage API key - Add custom error classes (VoyageAuthError, VoyageAPIError) to distinguish error types - Update generateVoyageEmbedding to throw VoyageAuthError for 401 responses - Return 401 status for authentication errors, 503 for API errors - Improve client-side error handling with user-friendly messages - Update tests to verify proper error handling for different scenarios --- mflix/client/app/aggregations/page.tsx | 4 +- mflix/client/app/lib/api.ts | 32 +++++-- mflix/client/app/movie/[id]/error.tsx | 2 +- mflix/client/app/movie/[id]/loading.tsx | 2 +- .../src/controllers/movieController.ts | 84 +++++++++++++++++-- .../tests/controllers/movieController.test.ts | 29 +++++-- 6 files changed, 131 insertions(+), 22 deletions(-) diff --git a/mflix/client/app/aggregations/page.tsx b/mflix/client/app/aggregations/page.tsx index a211141..00b8d16 100644 --- a/mflix/client/app/aggregations/page.tsx +++ b/mflix/client/app/aggregations/page.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { fetchMoviesWithComments, fetchMoviesByYear, fetchDirectorStats } from '../lib/api'; -import { MovieWithComments, YearlyStats, DirectorStats } from '../types/aggregations'; +import { fetchMoviesWithComments, fetchMoviesByYear, fetchDirectorStats } from '@/lib/api'; +import { MovieWithComments, YearlyStats, DirectorStats } from '@/types/aggregations'; import styles from './aggregations.module.css'; export default async function AggregationsPage() { diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index 9c77b47..2d20442 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -680,16 +680,36 @@ export async function vectorSearchMovies(searchParams: { const result = await response.json(); if (!response.ok) { - return { - success: false, - error: result.error || `Failed to perform vector search: ${response.status}` + // Extract error message from the standardized error response + const errorMessage = result.message || result.error?.message || `Failed to perform vector search: ${response.status}`; + const errorCode = result.error?.code; + + // Provide user-friendly messages for specific error codes + if (errorCode === 'VOYAGE_AUTH_ERROR') { + return { + success: false, + error: 'Vector search unavailable: Your Voyage AI API key is missing or invalid. Please add a valid VOYAGE_API_KEY to your .env file and restart the server.' + }; + } + + if (errorCode === 'SERVICE_UNAVAILABLE' || errorCode === 'VOYAGE_API_ERROR') { + return { + success: false, + error: errorMessage || 'Vector search service is currently unavailable. Please try again later.' + }; + } + + return { + success: false, + error: errorMessage }; } if (!result.success) { - return { - success: false, - error: result.error || 'API returned error response' + const errorMessage = result.message || result.error?.message || 'API returned error response'; + return { + success: false, + error: errorMessage }; } diff --git a/mflix/client/app/movie/[id]/error.tsx b/mflix/client/app/movie/[id]/error.tsx index 2e76a32..300904b 100644 --- a/mflix/client/app/movie/[id]/error.tsx +++ b/mflix/client/app/movie/[id]/error.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import Link from 'next/link'; -import { ROUTES } from '../../lib/constants'; +import { ROUTES } from '@/lib/constants'; import styles from './error.module.css'; export default function MovieDetailsError({ diff --git a/mflix/client/app/movie/[id]/loading.tsx b/mflix/client/app/movie/[id]/loading.tsx index dace3cf..89e004e 100644 --- a/mflix/client/app/movie/[id]/loading.tsx +++ b/mflix/client/app/movie/[id]/loading.tsx @@ -1,5 +1,5 @@ import pageStyles from './page.module.css'; -import { MovieDetailsSkeleton } from '../../components/LoadingSkeleton'; +import { MovieDetailsSkeleton } from '@/components'; export default function MovieDetailsLoading() { return ( diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index d913a09..86febc3 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -835,6 +835,36 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise representing the embedding vector + * @throws VoyageAuthError if the API key is invalid (401) + * @throws VoyageAPIError for other API errors */ async function generateVoyageEmbedding(text: string, apiKey: string): Promise { // Build the request body with output_dimension set to 2048 @@ -1192,21 +1247,36 @@ async function generateVoyageEmbedding(text: string, apiKey: string): Promise { ); }); - it("should handle Voyage AI API errors", async () => { + it("should handle Voyage AI authentication errors with 401 status", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, @@ -891,11 +891,30 @@ describe("Movie Controller Tests", () => { await vectorSearchMovies(mockRequest as Request, mockResponse as Response); - expect(mockStatus).toHaveBeenCalledWith(500); + expect(mockStatus).toHaveBeenCalledWith(401); expect(mockCreateErrorResponse).toHaveBeenCalledWith( - "Error performing vector search", - "VECTOR_SEARCH_ERROR", - expect.stringContaining("Voyage AI API returned status 401") + "Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file", + "VOYAGE_AUTH_ERROR", + "Please verify your VOYAGE_API_KEY is correct in the .env file" + ); + }); + + it("should handle other Voyage AI API errors with 503 status", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal Server Error"), + } as any); + + mockRequest.query = { q: "test" }; + + await vectorSearchMovies(mockRequest as Request, mockResponse as Response); + + expect(mockStatus).toHaveBeenCalledWith(503); + expect(mockCreateErrorResponse).toHaveBeenCalledWith( + "Vector search service unavailable", + "VOYAGE_API_ERROR", + expect.stringContaining("Voyage AI API returned status 500") ); }); From 7b68bdc7e2d34530743ede9c5be559fd922e2901 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Fri, 12 Dec 2025 17:59:07 -0500 Subject: [PATCH 2/5] Add Voyage AI error handling improvements to Python and Java backends - Python FastAPI backend: - Created custom exception classes (VoyageAuthError, VoyageAPIError) - Created error response utility function - Added global exception handlers in main.py - Updated vector search endpoint to use custom exceptions - Returns 400 for missing API key (SERVICE_UNAVAILABLE) - Returns 401 for invalid API key (VOYAGE_AUTH_ERROR) - Returns 503 for Voyage AI API errors (VOYAGE_API_ERROR) - Updated test to match new error handling behavior - Java Spring backend: - Created custom exception classes (VoyageAuthException, VoyageAPIException, ServiceUnavailableException) - Updated GlobalExceptionHandler with handlers for new exceptions - Updated MovieServiceImpl to throw custom exceptions - Returns 400 for missing API key (SERVICE_UNAVAILABLE) - Returns 401 for invalid API key (VOYAGE_AUTH_ERROR) - Returns 503 for Voyage AI API errors (VOYAGE_API_ERROR) - Updated tests to expect ServiceUnavailableException instead of ValidationException All three backends (Express, Python, Java) now have consistent Voyage AI error handling. --- .../exception/GlobalExceptionHandler.java | 55 +++++++++++++++++++ .../ServiceUnavailableException.java | 17 ++++++ .../exception/VoyageAPIException.java | 29 ++++++++++ .../exception/VoyageAuthException.java | 18 ++++++ .../samplemflix/service/MovieServiceImpl.java | 24 ++++++-- .../samplemflix/service/MovieServiceTest.java | 9 +-- mflix/server/python-fastapi/.python-version | 1 + mflix/server/python-fastapi/main.py | 30 +++++++++- .../python-fastapi/src/routers/movies.py | 55 ++++++++++++++++--- .../python-fastapi/src/utils/errorResponse.py | 38 +++++++++++++ .../python-fastapi/src/utils/exceptions.py | 36 ++++++++++++ .../python-fastapi/tests/test_movie_routes.py | 16 ++++-- 12 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java create mode 100644 mflix/server/python-fastapi/.python-version create mode 100644 mflix/server/python-fastapi/src/utils/errorResponse.py create mode 100644 mflix/server/python-fastapi/src/utils/exceptions.py diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java index aa6e544..3776dae 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/GlobalExceptionHandler.java @@ -79,6 +79,61 @@ public ResponseEntity handleMissingServletRequestParameter( return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } + @ExceptionHandler(ServiceUnavailableException.class) + public ResponseEntity handleServiceUnavailableException( + ServiceUnavailableException ex, WebRequest request) { + logger.error("Service unavailable: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("SERVICE_UNAVAILABLE") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(VoyageAuthException.class) + public ResponseEntity handleVoyageAuthException( + VoyageAuthException ex, WebRequest request) { + logger.error("Voyage AI authentication error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message(ex.getMessage()) + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VOYAGE_AUTH_ERROR") + .details("Please verify your VOYAGE_API_KEY is correct in the .env file") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(VoyageAPIException.class) + public ResponseEntity handleVoyageAPIException( + VoyageAPIException ex, WebRequest request) { + logger.error("Voyage AI API error: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .success(false) + .message("Vector search service unavailable") + .error(ErrorResponse.ErrorDetails.builder() + .message(ex.getMessage()) + .code("VOYAGE_API_ERROR") + .build()) + .timestamp(Instant.now().toString()) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.SERVICE_UNAVAILABLE); + } + @ExceptionHandler(DatabaseOperationException.class) public ResponseEntity handleDatabaseOperationException( DatabaseOperationException ex, WebRequest request) { diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..c515631 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/ServiceUnavailableException.java @@ -0,0 +1,17 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when a required service is unavailable or not configured. + * + * This exception results in a 400 Bad Request response with SERVICE_UNAVAILABLE code. + * Typically occurs when: + * - A required API key is not configured + * - A required service is not available + */ +public class ServiceUnavailableException extends RuntimeException { + + public ServiceUnavailableException(String message) { + super(message); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java new file mode 100644 index 0000000..6279a5c --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAPIException.java @@ -0,0 +1,29 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when Voyage AI API returns an error. + * + * This exception results in a 503 Service Unavailable response. + * Typically occurs when: + * - The Voyage AI API is down or unavailable + * - The API returns an error response + * - Network issues prevent communication with the API + */ +public class VoyageAPIException extends RuntimeException { + + private final int statusCode; + + public VoyageAPIException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public VoyageAPIException(String message) { + this(message, 503); + } + + public int getStatusCode() { + return statusCode; + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java new file mode 100644 index 0000000..2445db8 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/exception/VoyageAuthException.java @@ -0,0 +1,18 @@ +package com.mongodb.samplemflix.exception; + +/** + * Exception thrown when Voyage AI API authentication fails. + * + * This exception results in a 401 Unauthorized response. + * Typically occurs when: + * - The API key is invalid + * - The API key is missing + * - The API key has expired + */ +public class VoyageAuthException extends RuntimeException { + + public VoyageAuthException(String message) { + super(message); + } +} + diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java index f86bbb5..a0a8943 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -6,7 +6,10 @@ import com.mongodb.client.result.UpdateResult; import com.mongodb.samplemflix.exception.DatabaseOperationException; import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ServiceUnavailableException; import com.mongodb.samplemflix.exception.ValidationException; +import com.mongodb.samplemflix.exception.VoyageAPIException; +import com.mongodb.samplemflix.exception.VoyageAuthException; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.model.dto.*; import com.mongodb.samplemflix.repository.MovieRepository; @@ -821,8 +824,8 @@ public List vectorSearchMovies(String query, Integer limit) // Check if Voyage API key is configured if (voyageApiKey == null || voyageApiKey.trim().isEmpty() || voyageApiKey.equals("your_voyage_api_key")) { - throw new ValidationException( - "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your Voyage AI API key to the .env file" + throw new ServiceUnavailableException( + "Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file" ); } @@ -929,10 +932,16 @@ public List vectorSearchMovies(String query, Integer limit) return results; + } catch (VoyageAuthException e) { + // Re-raise Voyage AI authentication errors to be handled by GlobalExceptionHandler + throw e; + } catch (VoyageAPIException e) { + // Re-raise Voyage AI API errors to be handled by GlobalExceptionHandler + throw e; } catch (IOException e) { - // Handle Voyage AI API errors + // Handle network errors calling Voyage AI API String errorMsg = e.getMessage() != null ? e.getMessage() : "Network error calling Voyage AI API"; - throw new DatabaseOperationException("Error performing vector search: " + errorMsg); + throw new VoyageAPIException("Error performing vector search: " + errorMsg); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new DatabaseOperationException("Vector search was interrupted"); @@ -981,9 +990,12 @@ private List generateVoyageEmbedding(String text, String apiKey) throws if (response.statusCode() != 200) { // Handle authentication errors specifically if (response.statusCode() == 401) { - throw new IOException("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file"); + throw new VoyageAuthException("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file"); } - throw new IOException("Voyage AI API returned status code " + response.statusCode() + ": " + response.body()); + throw new VoyageAPIException( + "Voyage AI API returned status code " + response.statusCode() + ": " + response.body(), + response.statusCode() + ); } // Parse the JSON response to extract the embedding diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java index 6773e0b..335e763 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java @@ -8,6 +8,7 @@ import com.mongodb.client.MongoCollection; import com.mongodb.client.result.UpdateResult; import com.mongodb.samplemflix.exception.ResourceNotFoundException; +import com.mongodb.samplemflix.exception.ServiceUnavailableException; import com.mongodb.samplemflix.exception.ValidationException; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.model.dto.BatchInsertResponse; @@ -736,23 +737,23 @@ void testVectorSearchMovies_EmptyQuery() { } @Test - @DisplayName("Should throw ValidationException when API key is missing in vector search") + @DisplayName("Should throw ServiceUnavailableException when API key is missing in vector search") void testVectorSearchMovies_MissingApiKey() { // Arrange ReflectionTestUtils.setField(movieService, "voyageApiKey", null); // Act & Assert - assertThrows(ValidationException.class, () -> movieService.vectorSearchMovies("test query", 10)); + assertThrows(ServiceUnavailableException.class, () -> movieService.vectorSearchMovies("test query", 10)); } @Test - @DisplayName("Should throw ValidationException when API key is placeholder value in vector search") + @DisplayName("Should throw ServiceUnavailableException when API key is placeholder value in vector search") void testVectorSearchMovies_PlaceholderApiKey() { // Arrange ReflectionTestUtils.setField(movieService, "voyageApiKey", "your_voyage_api_key"); // Act & Assert - assertThrows(ValidationException.class, () -> movieService.vectorSearchMovies("test query", 10)); + assertThrows(ServiceUnavailableException.class, () -> movieService.vectorSearchMovies("test query", 10)); } @Test diff --git a/mflix/server/python-fastapi/.python-version b/mflix/server/python-fastapi/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/mflix/server/python-fastapi/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/mflix/server/python-fastapi/main.py b/mflix/server/python-fastapi/main.py index 6a770cb..99913ae 100644 --- a/mflix/server/python-fastapi/main.py +++ b/mflix/server/python-fastapi/main.py @@ -1,8 +1,11 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from src.routers import movies from src.database.mongo_client import db, get_collection +from src.utils.exceptions import VoyageAuthError, VoyageAPIError +from src.utils.errorResponse import create_error_response import os from dotenv import load_dotenv @@ -136,6 +139,31 @@ async def ensure_standard_index(): app = FastAPI(lifespan=lifespan) +# Add custom exception handlers +@app.exception_handler(VoyageAuthError) +async def voyage_auth_error_handler(request: Request, exc: VoyageAuthError): + """Handle Voyage AI authentication errors with 401 status.""" + return JSONResponse( + status_code=401, + content=create_error_response( + message=exc.message, + code="VOYAGE_AUTH_ERROR", + details="Please verify your VOYAGE_API_KEY is correct in the .env file" + ) + ) + +@app.exception_handler(VoyageAPIError) +async def voyage_api_error_handler(request: Request, exc: VoyageAPIError): + """Handle Voyage AI API errors with 503 status.""" + return JSONResponse( + status_code=503, + content=create_error_response( + message="Vector search service unavailable", + code="VOYAGE_API_ERROR", + details=exc.message + ) + ) + # Add CORS middleware cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",") app.add_middleware( diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index d8fa176..77b1142 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -1,12 +1,16 @@ from fastapi import APIRouter, Query, Path, Body, HTTPException +from fastapi.responses import JSONResponse from src.database.mongo_client import get_collection, voyage_ai_available from src.models.models import VectorSearchResult, CreateMovieRequest, Movie, SuccessResponse, UpdateMovieRequest, SearchMoviesResponse from typing import Any, List, Optional from src.utils.successResponse import create_success_response +from src.utils.errorResponse import create_error_response +from src.utils.exceptions import VoyageAuthError, VoyageAPIError from bson import ObjectId, errors import re from bson.errors import InvalidId import voyageai +import os ''' @@ -316,11 +320,16 @@ async def vector_search_movies( Returns: SuccessResponse containing a list of movies with similarity scores """ + # Check if Voyage AI API key is configured if not voyage_ai_available(): - raise HTTPException( - status_code = 503, - detail="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to your .env file." + return JSONResponse( + status_code=400, + content=create_error_response( + message="Vector search unavailable: VOYAGE_API_KEY not configured. Please add your API key to the .env file", + code="SERVICE_UNAVAILABLE" + ) ) + try: # Initialize the client here to avoid import-time errors vo = voyageai.Client() @@ -390,10 +399,20 @@ async def vector_search_movies( f"Found {len(results)} similar movies for query: '{q}'" ) + except VoyageAuthError: + # Re-raise custom exceptions to be handled by the exception handlers + raise + except VoyageAPIError: + # Re-raise custom exceptions to be handled by the exception handlers + raise except Exception as e: + # Log the error for debugging + print(f"Vector search error: {str(e)}") + + # Handle generic errors raise HTTPException( - status_code = 500, - detail=f"An error occurred during vector search: {str(e)}" + status_code=500, + detail=f"Error performing vector search: {str(e)}" ) """ @@ -1348,12 +1367,30 @@ def get_embedding(data, input_type = "document", client=None): Returns: Vector embeddings for the given input + + Raises: + VoyageAuthError: If the API key is invalid (401) + VoyageAPIError: For other API errors """ if client is None: client = voyageai.Client() - embeddings = client.embed( - data, model = model, output_dimension = outputDimension, input_type = input_type - ).embeddings - return embeddings[0] + try: + embeddings = client.embed( + data, model = model, output_dimension = outputDimension, input_type = input_type + ).embeddings + return embeddings[0] + except Exception as e: + error_message = str(e).lower() + + # Check for authentication errors + if "401" in error_message or "unauthorized" in error_message or "invalid api key" in error_message: + raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") + + # Check for other API errors + if "api" in error_message or "voyage" in error_message: + raise VoyageAPIError(f"Voyage AI API error: {str(e)}", 503) + + # Re-raise other exceptions + raise VoyageAPIError(f"Failed to generate embedding: {str(e)}", 500) diff --git a/mflix/server/python-fastapi/src/utils/errorResponse.py b/mflix/server/python-fastapi/src/utils/errorResponse.py new file mode 100644 index 0000000..020d01f --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/errorResponse.py @@ -0,0 +1,38 @@ +""" +Utility functions for creating standardized error responses. + +This module provides functions to create consistent error response structures +that match the Express backend's error format. +""" + +from datetime import datetime +from typing import Optional, Any + + +def create_error_response( + message: str, + code: Optional[str] = None, + details: Optional[Any] = None +) -> dict: + """ + Creates a standardized error response. + + Args: + message: The error message to display + code: Optional error code (e.g., 'VOYAGE_AUTH_ERROR', 'VOYAGE_API_ERROR') + details: Optional additional error details + + Returns: + A dictionary containing the standardized error response + """ + return { + "success": False, + "message": message, + "error": { + "message": message, + "code": code, + "details": details + }, + "timestamp": datetime.utcnow().isoformat() + "Z" + } + diff --git a/mflix/server/python-fastapi/src/utils/exceptions.py b/mflix/server/python-fastapi/src/utils/exceptions.py new file mode 100644 index 0000000..7ef6467 --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/exceptions.py @@ -0,0 +1,36 @@ +""" +Custom exception classes for the FastAPI application. + +This module defines custom exceptions for handling specific error scenarios, +particularly for Voyage AI API interactions. +""" + +class VoyageAuthError(Exception): + """ + Exception raised when Voyage AI API authentication fails. + + This typically occurs when: + - The API key is invalid + - The API key is missing + - The API key has expired + """ + def __init__(self, message: str = "Invalid Voyage AI API key"): + self.message = message + super().__init__(self.message) + + +class VoyageAPIError(Exception): + """ + Exception raised when Voyage AI API returns an error. + + This covers general API errors such as: + - Rate limiting + - Service unavailability + - Invalid requests + - Server errors + """ + def __init__(self, message: str, status_code: int = 500): + self.message = message + self.status_code = status_code + super().__init__(self.message) + diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index ce4b3b6..edca2bc 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -763,12 +763,20 @@ async def test_vector_search_unavailable(self, mock_voyage_available): # Call the route handler from src.routers.movies import vector_search_movies - with pytest.raises(HTTPException) as e: - await vector_search_movies(q="action movie") + from fastapi.responses import JSONResponse + + response = await vector_search_movies(q="action movie") # Assertions - assert e.value.status_code == 503 - assert str("VOYAGE_API_KEY not configured").lower() in str(e.value.detail).lower() + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + + # Parse the response body + import json + body = json.loads(response.body.decode()) + assert body["success"] is False + assert body["error"]["code"] == "SERVICE_UNAVAILABLE" + assert "VOYAGE_API_KEY not configured" in body["message"] @patch('src.routers.movies.voyage_ai_available') From 695161c58919570d5ce8f49e5b89ebca6d4b6c05 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Fri, 12 Dec 2025 18:00:53 -0500 Subject: [PATCH 3/5] Fix deprecated datetime.utcnow() warning in Python error response Replace datetime.utcnow() with datetime.now(timezone.utc) to fix deprecation warning in Python 3.12+. --- mflix/server/python-fastapi/src/utils/errorResponse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mflix/server/python-fastapi/src/utils/errorResponse.py b/mflix/server/python-fastapi/src/utils/errorResponse.py index 020d01f..d82e144 100644 --- a/mflix/server/python-fastapi/src/utils/errorResponse.py +++ b/mflix/server/python-fastapi/src/utils/errorResponse.py @@ -5,7 +5,7 @@ that match the Express backend's error format. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, Any @@ -33,6 +33,6 @@ def create_error_response( "code": code, "details": details }, - "timestamp": datetime.utcnow().isoformat() + "Z" + "timestamp": datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') } From 727f84f912e2f9f136e2f80d09c5b5cec1bbf4ae Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Mon, 15 Dec 2025 15:27:05 -0500 Subject: [PATCH 4/5] Fix Python Voyage AI error handling to use SDK exception types - Remove accidentally added .python-version file - Update get_embedding() to catch specific voyageai.error exceptions: - AuthenticationError (401) -> VoyageAuthError - InvalidRequestError (400) -> VoyageAPIError - RateLimitError (429) -> VoyageAPIError - ServiceUnavailableError (503) -> VoyageAPIError - VoyageError (other) -> VoyageAPIError - Import VoyageAuthError and VoyageAPIError in tests for future use This fixes the issue where authentication errors were not being properly detected because the code was relying on string matching instead of catching the SDK's specific exception types. --- mflix/server/python-fastapi/.python-version | 1 - .../python-fastapi/src/routers/movies.py | 28 +++++++++++-------- .../python-fastapi/tests/test_movie_routes.py | 1 + 3 files changed, 18 insertions(+), 12 deletions(-) delete mode 100644 mflix/server/python-fastapi/.python-version diff --git a/mflix/server/python-fastapi/.python-version b/mflix/server/python-fastapi/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/mflix/server/python-fastapi/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 77b1142..2f70458 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -10,6 +10,7 @@ import re from bson.errors import InvalidId import voyageai +import voyageai.error as voyage_error import os @@ -1380,17 +1381,22 @@ def get_embedding(data, input_type = "document", client=None): data, model = model, output_dimension = outputDimension, input_type = input_type ).embeddings return embeddings[0] + except voyage_error.AuthenticationError as e: + # Handle authentication errors (401) from Voyage AI SDK + raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") + except voyage_error.InvalidRequestError as e: + # Handle invalid request errors (400) - often due to malformed API key + raise VoyageAPIError(f"Invalid request to Voyage AI API: {str(e)}", 400) + except voyage_error.RateLimitError as e: + # Handle rate limiting errors (429) + raise VoyageAPIError(f"Voyage AI API rate limit exceeded: {str(e)}", 429) + except voyage_error.ServiceUnavailableError as e: + # Handle service unavailable errors (502, 503, 504) + raise VoyageAPIError(f"Voyage AI service unavailable: {str(e)}", 503) + except voyage_error.VoyageError as e: + # Handle any other Voyage AI SDK errors + raise VoyageAPIError(f"Voyage AI API error: {str(e)}", getattr(e, 'http_status', 500) or 500) except Exception as e: - error_message = str(e).lower() - - # Check for authentication errors - if "401" in error_message or "unauthorized" in error_message or "invalid api key" in error_message: - raise VoyageAuthError("Invalid Voyage AI API key. Please check your VOYAGE_API_KEY in the .env file") - - # Check for other API errors - if "api" in error_message or "voyage" in error_message: - raise VoyageAPIError(f"Voyage AI API error: {str(e)}", 503) - - # Re-raise other exceptions + # Handle unexpected errors raise VoyageAPIError(f"Failed to generate embedding: {str(e)}", 500) diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index edca2bc..5256fab 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -12,6 +12,7 @@ from fastapi import HTTPException from src.models.models import CreateMovieRequest, UpdateMovieRequest +from src.utils.exceptions import VoyageAuthError, VoyageAPIError # Test constants From 4972a4e8b94a75004e311395814d3dd76fb1130e Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 16 Dec 2025 09:40:14 -0500 Subject: [PATCH 5/5] Fix: Move voyageai.Client() creation inside try/except block The voyageai.Client() constructor can raise AuthenticationError if the API key is empty or invalid. Previously, this was happening outside the try/except block that catches Voyage AI exceptions, causing the error to be caught by the generic Exception handler and returned as a 500 error instead of a 401. This fix moves the client creation inside the try block so that AuthenticationError from the constructor is properly caught and converted to a VoyageAuthError with a 401 status code. --- mflix/server/python-fastapi/src/routers/movies.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 2f70458..f15d6bc 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -332,12 +332,9 @@ async def vector_search_movies( ) try: - # Initialize the client here to avoid import-time errors - vo = voyageai.Client() - # The vector search index was already created at startup time - # Generate embedding for the search query - query_embedding = get_embedding(q, input_type="query", client=vo) + # Generate embedding for the search query (client is created inside get_embedding) + query_embedding = get_embedding(q, input_type="query") # Get the embedded movies collection embedded_movies_collection = get_collection("embedded_movies") @@ -1373,10 +1370,10 @@ def get_embedding(data, input_type = "document", client=None): VoyageAuthError: If the API key is invalid (401) VoyageAPIError: For other API errors """ - if client is None: - client = voyageai.Client() - try: + if client is None: + client = voyageai.Client() + embeddings = client.embed( data, model = model, output_dimension = outputDimension, input_type = input_type ).embeddings