Skip to main content
Glama
client.py10.9 kB
""" Mealie API Client A robust synchronous HTTP client for interacting with the Mealie API. Includes error handling, retry logic, and connection testing. """ import os import time from typing import Any, Dict, Optional, Union from urllib.parse import urljoin import httpx from dotenv import load_dotenv class MealieAPIError(Exception): """Base exception for Mealie API errors.""" def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None): self.status_code = status_code self.response_body = response_body super().__init__(message) class MealieClient: """ Synchronous client for the Mealie API. Features: - Bearer token authentication - Automatic retry with exponential backoff - Comprehensive error handling - Connection testing Environment Variables: - MEALIE_URL: Base URL of the Mealie instance (required) - MEALIE_API_TOKEN: API token for authentication (required) """ MAX_RETRIES = 3 RETRY_DELAYS = [1, 2, 4] # Exponential backoff: 1s, 2s, 4s TIMEOUT = 30.0 # seconds def __init__(self, base_url: Optional[str] = None, api_token: Optional[str] = None): """ Initialize the Mealie API client. Args: base_url: Base URL of the Mealie instance (defaults to MEALIE_URL env var) api_token: API token for authentication (defaults to MEALIE_API_TOKEN env var) Raises: ValueError: If required configuration is missing """ self.base_url = base_url or os.getenv("MEALIE_URL") self.api_token = api_token or os.getenv("MEALIE_API_TOKEN") if not self.base_url: raise ValueError("MEALIE_URL must be set in environment or passed to constructor") if not self.api_token: raise ValueError("MEALIE_API_TOKEN must be set in environment or passed to constructor") # Ensure base URL doesn't end with trailing slash self.base_url = self.base_url.rstrip("/") # Set up HTTP client with auth headers self.headers = { "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", "Accept": "application/json", } # Create synchronous HTTP client self.client = httpx.Client( headers=self.headers, timeout=self.TIMEOUT, follow_redirects=True, ) def __enter__(self): """Context manager support.""" return self def __exit__(self, exc_type, exc_val, exc_tb): """Close client on context manager exit.""" self.close() def close(self): """Close the HTTP client.""" self.client.close() def _build_url(self, endpoint: str) -> str: """ Build full URL from endpoint. Args: endpoint: API endpoint path (e.g., "/api/recipes") Returns: Full URL """ # Ensure endpoint starts with / if not endpoint.startswith("/"): endpoint = f"/{endpoint}" return urljoin(self.base_url, endpoint) def _should_retry(self, exception: Exception, attempt: int) -> bool: """ Determine if request should be retried. Args: exception: Exception that occurred attempt: Current attempt number (0-indexed) Returns: True if should retry, False otherwise """ if attempt >= self.MAX_RETRIES: return False # Retry on connection errors if isinstance(exception, (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)): return True # Retry on 5xx server errors if isinstance(exception, httpx.HTTPStatusError): return exception.response.status_code >= 500 return False def _make_request( self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None, json: Optional[Dict[str, Any]] = None, ) -> Any: """ Make HTTP request with retry logic. Args: method: HTTP method (GET, POST, PUT, DELETE) endpoint: API endpoint path params: Query parameters data: Form data json: JSON body Returns: Response JSON data Raises: MealieAPIError: If request fails after retries """ url = self._build_url(endpoint) for attempt in range(self.MAX_RETRIES + 1): try: response = self.client.request( method=method, url=url, params=params, data=data, json=json, ) # Raise for 4xx and 5xx status codes response.raise_for_status() # Return JSON if present, otherwise return None if response.content: try: return response.json() except Exception: return response.text return None except httpx.HTTPStatusError as e: # For 4xx errors, don't retry if 400 <= e.response.status_code < 500: raise MealieAPIError( f"Client error {e.response.status_code}: {e.response.text}", status_code=e.response.status_code, response_body=e.response.text, ) # For 5xx errors, retry if attempts remain if self._should_retry(e, attempt): delay = self.RETRY_DELAYS[attempt] time.sleep(delay) continue raise MealieAPIError( f"Server error {e.response.status_code}: {e.response.text}", status_code=e.response.status_code, response_body=e.response.text, ) except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as e: # Retry connection errors if self._should_retry(e, attempt): delay = self.RETRY_DELAYS[attempt] time.sleep(delay) continue raise MealieAPIError( f"Connection error: {str(e)}", ) except Exception as e: raise MealieAPIError( f"Unexpected error: {str(e)}", ) # Should never reach here, but just in case raise MealieAPIError("Request failed after maximum retries") def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any: """ Perform GET request. Args: endpoint: API endpoint path params: Query parameters Returns: Response JSON data """ return self._make_request("GET", endpoint, params=params) def post( self, endpoint: str, data: Optional[Dict[str, Any]] = None, json: Optional[Dict[str, Any]] = None, ) -> Any: """ Perform POST request. Args: endpoint: API endpoint path data: Form data json: JSON body Returns: Response JSON data """ return self._make_request("POST", endpoint, data=data, json=json) def put(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> Any: """ Perform PUT request. Args: endpoint: API endpoint path json: JSON body Returns: Response JSON data """ return self._make_request("PUT", endpoint, json=json) def delete(self, endpoint: str) -> Any: """ Perform DELETE request. Args: endpoint: API endpoint path Returns: Response JSON data """ return self._make_request("DELETE", endpoint) def test_connection(self) -> bool: """ Test connection to Mealie instance. Calls /api/app/about endpoint to verify connectivity and authentication. Returns: True if connection successful Raises: MealieAPIError: If connection fails with details """ try: response = self.get("/api/app/about") # Verify we got a response if response is None: raise MealieAPIError("Empty response from /api/app/about") # If response is a dict, check for expected fields if isinstance(response, dict): # Mealie /api/app/about typically returns version info if "version" in response or "apiVersion" in response or "versionAPI" in response: return True # Response looks valid even without these specific fields return True # Got some response, consider it successful return True except MealieAPIError as e: # Re-raise with more context raise MealieAPIError( f"Connection test failed: {str(e)}", status_code=e.status_code, response_body=e.response_body, ) if __name__ == "__main__": """ Test the Mealie client against the live instance. """ print("Testing Mealie API Client...") print("-" * 50) # Load environment variables from .env file load_dotenv() try: # Create client print("Creating MealieClient...") with MealieClient() as client: print(f"Base URL: {client.base_url}") print(f"Token: {client.api_token[:20]}..." if client.api_token else "Token: None") print() # Test connection print("Testing connection to Mealie instance...") result = client.test_connection() print(f"Connection test: {'SUCCESS' if result else 'FAILED'}") print() # Try to get about info print("Fetching /api/app/about...") about = client.get("/api/app/about") print(f"Response: {about}") print() print("-" * 50) print("All tests passed!") except MealieAPIError as e: print(f"ERROR: {e}") if e.status_code: print(f"Status Code: {e.status_code}") if e.response_body: print(f"Response: {e.response_body}") print("-" * 50) print("Tests failed!") exit(1) except Exception as e: print(f"UNEXPECTED ERROR: {e}") print("-" * 50) print("Tests failed!") exit(1)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mdlopresti/mealie-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server