Add health check endpoint and refactor integration response handling

- Introduced a new public health check endpoint at `api/ping/` to verify API responsiveness.
- Refactored integration response handling to utilize a unified success and error response format across various methods in `IntegrationSettingsViewSet`, improving consistency and clarity in API responses.
- Updated URL patterns to include the new ping endpoint and adjusted imports accordingly.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-16 07:01:19 +00:00
parent 201bc339a8
commit 7cd0e1a807
9 changed files with 376 additions and 308 deletions

View File

@@ -5,7 +5,6 @@ Unified API Standard v1.0 compliant
import logging
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db import transaction
from drf_spectacular.utils import extend_schema, extend_schema_view
from igny8_core.api.base import AccountModelViewSet
@@ -140,7 +139,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})")
try:
if integration_type == 'openai':
return self._test_openai(api_key, config)
return self._test_openai(api_key, config, request)
elif integration_type == 'runware':
return self._test_runware(api_key, request)
else:
@@ -160,7 +159,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
request=request
)
def _test_openai(self, api_key: str, config: dict = None):
def _test_openai(self, api_key: str, config: dict = None, request=None):
"""
Test OpenAI API connection.
EXACT match to reference plugin's igny8_test_connection() function.
@@ -215,33 +214,38 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000
return Response({
'success': True,
'message': 'API connection and response test successful!',
'model_used': model,
'response': response_text,
'tokens_used': f"{input_tokens} / {output_tokens}",
'total_tokens': total_tokens,
'cost': f'${cost:.4f}',
'full_response': response_data,
})
return success_response(
data={
'message': 'API connection and response test successful!',
'model_used': model,
'response': response_text,
'tokens_used': f"{input_tokens} / {output_tokens}",
'total_tokens': total_tokens,
'cost': f'${cost:.4f}',
'full_response': response_data,
},
request=request
)
else:
return Response({
'success': False,
'message': 'API responded but no content received',
'response': response.text[:500]
})
return error_response(
error='API responded but no content received',
errors={'response': [response.text[:500]]},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
else:
body = response.text
return Response({
'success': False,
'message': f'HTTP {response.status_code} {body[:200]}'
}, status=response.status_code)
return error_response(
error=f'HTTP {response.status_code} {body[:200]}',
status_code=response.status_code,
request=request
)
except requests.exceptions.RequestException as e:
return Response({
'success': False,
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
else:
# Simple connection test without API call (reference plugin: GET /v1/models)
try:
@@ -254,23 +258,27 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
)
if response.status_code >= 200 and response.status_code < 300:
return Response({
'success': True,
'message': 'API connection successful!',
'model_used': model,
'response': 'Connection verified without API call'
})
return success_response(
data={
'message': 'API connection successful!',
'model_used': model,
'response': 'Connection verified without API call'
},
request=request
)
else:
body = response.text
return Response({
'success': False,
'message': f'HTTP {response.status_code} {body[:200]}'
}, status=response.status_code)
return error_response(
error=f'HTTP {response.status_code} {body[:200]}',
status_code=response.status_code,
request=request
)
except requests.exceptions.RequestException as e:
return Response({
'success': False,
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def _test_runware(self, api_key: str, request):
"""
@@ -338,11 +346,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if response.status_code != 200:
error_text = response.text
logger.error(f"[_test_runware] HTTP error {response.status_code}: {error_text[:200]}")
return Response({
'success': False,
'error': f'HTTP {response.status_code}: {error_text[:200]}',
'message': 'Runware API validation failed'
}, status=response.status_code)
return error_response(
error=f'HTTP {response.status_code}: {error_text[:200]}',
status_code=response.status_code,
request=request
)
# Parse response - Reference plugin checks: $body['data'][0]['imageURL']
body = response.json()
@@ -357,15 +365,17 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
image_url = first_item.get('imageURL') or first_item.get('image_url')
if image_url:
logger.info(f"[_test_runware] Success! Image URL: {image_url[:50]}...")
return Response({
'success': True,
'message': '✅ Runware API connected successfully!',
'image_url': image_url,
'cost': '$0.0090',
'provider': 'runware',
'model': 'runware:97@1',
'size': '128x128'
})
return success_response(
data={
'message': '✅ Runware API connected successfully!',
'image_url': image_url,
'cost': '$0.0090',
'provider': 'runware',
'model': 'runware:97@1',
'size': '128x128'
},
request=request
)
# Check for errors - Reference plugin line 4998: elseif (isset($body['errors'][0]['message']))
if isinstance(body, dict) and 'errors' in body:
@@ -373,26 +383,26 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if isinstance(errors, list) and len(errors) > 0:
error_msg = errors[0].get('message', 'Unknown Runware API error')
logger.error(f"[_test_runware] Runware API error: {error_msg}")
return Response({
'success': False,
'error': f'{error_msg}',
'message': 'Runware API validation failed'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=f'{error_msg}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
# Unknown response format
logger.error(f"[_test_runware] Unknown response format: {body}")
return Response({
'success': False,
'error': '❌ Unknown response from Runware.',
'message': 'Runware API validation failed'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error='❌ Unknown response from Runware.',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
except Exception as e:
logger.error(f"[_test_runware] Exception in Runware API test: {str(e)}", exc_info=True)
return Response({
'success': False,
'error': f'Runware API test failed: {str(e)}',
'message': 'Runware API validation failed'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=f'Runware API test failed: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def generate_image(self, request, pk=None, **kwargs):
"""
@@ -419,10 +429,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if pk != 'image_generation':
logger.error(f"[generate_image] Invalid pk: {pk}, expected 'image_generation'")
return Response({
'success': False,
'error': f'Image generation endpoint only available for image_generation integration, got: {pk}'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error=f'Image generation endpoint only available for image_generation integration, got: {pk}',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get account
logger.info("[generate_image] Step 1: Getting account")
@@ -445,10 +456,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if not account:
logger.error("[generate_image] ERROR: No account found, returning error response")
return Response({
'success': False,
'error': 'Account not found'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='Account not found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
logger.info(f"[generate_image] Account resolved: {account.id if account else 'None'}")
@@ -467,10 +479,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if not prompt:
logger.error("[generate_image] ERROR: Prompt is empty")
return Response({
'success': False,
'error': 'Prompt is required'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='Prompt is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get API key from saved settings for the specified provider only
logger.info(f"[generate_image] Step 3: Getting API key for provider: {provider}")
@@ -502,17 +515,19 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
logger.info(f"[generate_image] Step 4: Validating {provider} provider and API key")
if provider not in ['openai', 'runware']:
logger.error(f"[generate_image] ERROR: Invalid provider: {provider}")
return Response({
'success': False,
'error': f'Invalid provider: {provider}. Must be "openai" or "runware"'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error=f'Invalid provider: {provider}. Must be "openai" or "runware"',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if not api_key or not integration_enabled:
logger.error(f"[generate_image] ERROR: {provider.upper()} API key not configured or integration not enabled")
return Response({
'success': False,
'error': f'{provider.upper()} API key not configured or integration not enabled'
}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error=f'{provider.upper()} API key not configured or integration not enabled',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
logger.info(f"[generate_image] {provider.upper()} API key validated successfully")
@@ -543,14 +558,14 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if result.get('error'):
logger.error(f"[generate_image] ERROR from AIProcessor: {result.get('error')}")
return Response({
'success': False,
'error': result['error']
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=result['error'],
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
logger.info("[generate_image] Image generation successful, returning response")
response_data = {
'success': True,
'image_url': result.get('url'),
'revised_prompt': result.get('revised_prompt'),
'model': model,
@@ -558,19 +573,27 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
'cost': f"${result.get('cost', 0):.4f}" if result.get('cost') else None,
}
logger.info(f"[generate_image] Returning success response: {response_data}")
return Response(response_data)
return success_response(
data=response_data,
request=request
)
except Exception as e:
logger.error(f"[generate_image] EXCEPTION in image generation: {str(e)}", exc_info=True)
return Response({
'success': False,
'error': f'Failed to generate image: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=f'Failed to generate image: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def create(self, request):
"""Create integration settings"""
integration_type = request.data.get('integration_type')
if not integration_type:
return Response({'error': 'integration_type is required'}, status=status.HTTP_400_BAD_REQUEST)
return error_response(
error='integration_type is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
return self.save_settings(request, integration_type)
def save_settings(self, request, pk=None):
@@ -693,9 +716,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
import traceback
error_trace = traceback.format_exc()
logger.error(f"Full traceback: {error_trace}")
return Response({
'error': f'Failed to save settings: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=f'Failed to save settings: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def get_settings(self, request, pk=None):
"""Get integration settings - defaults to AWS-admin settings if account doesn't have its own"""
@@ -772,10 +797,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
pass
if not account:
return Response({
'error': 'Account not found',
'type': 'AuthenticationError'
}, status=status.HTTP_401_UNAUTHORIZED)
return error_response(
error='Account not found',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
try:
from .models import IntegrationSettings
@@ -800,39 +826,44 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
provider = config.get('provider', 'openai')
default_featured_size = '1280x832' if provider == 'runware' else '1024x1024'
return Response({
'success': True,
'config': {
'provider': config.get('provider', 'openai'),
'model': model,
'image_type': config.get('image_type', 'realistic'),
'max_in_article_images': config.get('max_in_article_images', 2),
'image_format': config.get('image_format', 'webp'),
'desktop_enabled': config.get('desktop_enabled', True),
'mobile_enabled': config.get('mobile_enabled', True),
'featured_image_size': config.get('featured_image_size', default_featured_size),
'desktop_image_size': config.get('desktop_image_size', '1024x1024'),
}
}, status=status.HTTP_200_OK)
return success_response(
data={
'config': {
'provider': config.get('provider', 'openai'),
'model': model,
'image_type': config.get('image_type', 'realistic'),
'max_in_article_images': config.get('max_in_article_images', 2),
'image_format': config.get('image_format', 'webp'),
'desktop_enabled': config.get('desktop_enabled', True),
'mobile_enabled': config.get('mobile_enabled', True),
'featured_image_size': config.get('featured_image_size', default_featured_size),
'desktop_image_size': config.get('desktop_image_size', '1024x1024'),
}
},
request=request
)
except IntegrationSettings.DoesNotExist:
return Response({
'success': True,
'config': {
'provider': 'openai',
'model': 'dall-e-3',
'image_type': 'realistic',
'max_in_article_images': 2,
'image_format': 'webp',
'desktop_enabled': True,
'mobile_enabled': True,
}
}, status=status.HTTP_200_OK)
return success_response(
data={
'config': {
'provider': 'openai',
'model': 'dall-e-3',
'image_type': 'realistic',
'max_in_article_images': 2,
'image_format': 'webp',
'desktop_enabled': True,
'mobile_enabled': True,
}
},
request=request
)
except Exception as e:
logger.error(f"[get_image_generation_settings] Error: {str(e)}", exc_info=True)
return Response({
'error': str(e),
'type': 'ServerError'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['get'], url_path='task_progress/(?P<task_id>[^/.]+)', url_name='task-progress')
def task_progress(self, request, task_id=None):
@@ -841,9 +872,10 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
GET /api/v1/system/settings/task_progress/<task_id>/
"""
if not task_id:
return Response(
{'error': 'Task ID is required'},
status=status.HTTP_400_BAD_REQUEST
return error_response(
error='Task ID is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
import logging
@@ -862,14 +894,18 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
RedisConnectionError = ConnectionError
except ImportError:
logger.warning("Celery not available - task progress cannot be retrieved")
return Response({
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Celery not available - cannot retrieve task status',
'error': 'Celery not configured'
}
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return success_response(
data={
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Celery not available - cannot retrieve task status',
'error': 'Celery not configured'
}
},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
try:
# Create AsyncResult - this should not raise an exception even if task doesn't exist
@@ -937,51 +973,64 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
except Exception as e:
logger.debug(f"Error extracting error from task.info: {str(e)}")
return Response({
'state': 'FAILURE',
'meta': {
'error': error_msg,
'error_type': error_type,
'percentage': 0,
'message': f'Error: {error_msg}',
'request_steps': request_steps,
'response_steps': response_steps,
}
})
return success_response(
data={
'state': 'FAILURE',
'meta': {
'error': error_msg,
'error_type': error_type,
'percentage': 0,
'message': f'Error: {error_msg}',
'request_steps': request_steps,
'response_steps': response_steps,
}
},
request=request
)
except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc:
# Backend connection error - task might not be registered yet or backend is down
logger.warning(f"Backend connection error accessing task.state for {task_id}: {type(conn_exc).__name__}: {str(conn_exc)}")
return Response({
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task is being queued...',
'phase': 'initializing',
'error': None # Don't show as error, just pending
}
})
return success_response(
data={
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task is being queued...',
'phase': 'initializing',
'error': None # Don't show as error, just pending
}
},
request=request
)
except Exception as state_exc:
logger.error(f"Unexpected error accessing task.state: {type(state_exc).__name__}: {str(state_exc)}")
return Response({
'state': 'UNKNOWN',
'meta': {
'error': f'Error accessing task: {str(state_exc)}',
'percentage': 0,
'message': f'Error: {str(state_exc)}',
}
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return success_response(
data={
'state': 'UNKNOWN',
'meta': {
'error': f'Error accessing task: {str(state_exc)}',
'percentage': 0,
'message': f'Error: {str(state_exc)}',
}
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
# Check if task exists and is accessible
if task_state is None:
# Task doesn't exist or hasn't been registered yet
return Response({
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task not found or not yet registered',
'phase': 'initializing',
}
})
return success_response(
data={
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task not found or not yet registered',
'phase': 'initializing',
}
},
request=request
)
# Safely get task info/result
# Try to get error from multiple sources
@@ -1114,10 +1163,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
# Include image_queue if available (for image generation)
if 'image_queue' in meta:
response_meta['image_queue'] = meta['image_queue']
return Response({
'state': task_state,
'meta': response_meta
})
return success_response(
data={
'state': task_state,
'meta': response_meta
},
request=request
)
elif task_state == 'SUCCESS':
result = task_result or {}
meta = result if isinstance(result, dict) else {}
@@ -1133,10 +1185,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
response_meta['request_steps'] = meta['request_steps']
if 'response_steps' in meta:
response_meta['response_steps'] = meta['response_steps']
return Response({
'state': task_state,
'meta': response_meta
})
return success_response(
data={
'state': task_state,
'meta': response_meta
},
request=request
)
elif task_state == 'FAILURE':
# Try to get error from task.info meta first (this is where run_ai_task sets it)
if not error_message and isinstance(task_info, dict):
@@ -1211,42 +1266,55 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
error_type = meta['error_type']
response_meta['error_type'] = error_type
return Response({
'state': task_state,
'meta': response_meta
})
return success_response(
data={
'state': task_state,
'meta': response_meta
},
request=request
)
else:
# PENDING, STARTED, or other states
return Response({
'state': task_state,
'meta': {
'percentage': 0,
'message': 'Task is starting...',
'phase': 'initializing',
}
})
return success_response(
data={
'state': task_state,
'meta': {
'percentage': 0,
'message': 'Task is starting...',
'phase': 'initializing',
}
},
request=request
)
except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_error:
# Backend connection error - task might not be registered yet or backend is down
logger.warning(f"Backend connection error for task {task_id}: {type(conn_error).__name__}: {str(conn_error)}")
return Response({
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task is being queued...',
'phase': 'initializing',
'error': None # Don't show as error, just pending
}
})
return success_response(
data={
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task is being queued...',
'phase': 'initializing',
'error': None # Don't show as error, just pending
}
},
request=request
)
except Exception as task_error:
logger.error(f"Error accessing Celery task {task_id}: {type(task_error).__name__}: {str(task_error)}", exc_info=True)
return Response({
'state': 'UNKNOWN',
'meta': {
'percentage': 0,
'message': f'Error accessing task: {str(task_error)}',
'error': str(task_error)
}
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return success_response(
data={
'state': 'UNKNOWN',
'meta': {
'percentage': 0,
'message': f'Error accessing task: {str(task_error)}',
'error': str(task_error)
}
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
except Exception as e:
# Check if it's a connection-related error - treat as PENDING instead of error
@@ -1263,19 +1331,22 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if is_connection_error:
logger.warning(f"Connection error getting task progress for {task_id}: {error_type}: {str(e)}")
return Response({
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task is being queued...',
'phase': 'initializing',
'error': None
}
})
return success_response(
data={
'state': 'PENDING',
'meta': {
'percentage': 0,
'message': 'Task is being queued...',
'phase': 'initializing',
'error': None
}
},
request=request
)
else:
logger.error(f"Error getting task progress for {task_id}: {error_type}: {str(e)}", exc_info=True)
return Response(
{
return success_response(
data={
'state': 'ERROR',
'meta': {
'error': f'Error getting task status: {str(e)}',
@@ -1283,6 +1354,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
'message': f'Error: {str(e)}'
}
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)

View File

@@ -3,7 +3,7 @@ URL patterns for system module.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, system_status, get_request_metrics, gitea_webhook
from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, system_status, get_request_metrics, gitea_webhook, ping
from .integration_views import IntegrationSettingsViewSet
from .settings_views import (
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
@@ -51,6 +51,8 @@ integration_image_gen_settings_viewset = IntegrationSettingsViewSet.as_view({
urlpatterns = [
path('', include(router.urls)),
# Public health check endpoint (API Standard v1.0 requirement)
path('ping/', ping, name='system-ping'),
# System status endpoint
path('status/', system_status, name='system-status'),
# Request metrics endpoint

View File

@@ -269,6 +269,24 @@ class StrategyViewSet(AccountModelViewSet):
filterset_fields = ['is_active', 'sector']
@api_view(['GET'])
@permission_classes([AllowAny]) # Public endpoint
@extend_schema(
tags=['System'],
summary='Health Check',
description='Simple health check endpoint to verify API is responding'
)
def ping(request):
"""
Simple health check endpoint
Returns unified format: {success: true, data: {status: 'ok'}}
"""
return success_response(
data={'status': 'ok'},
request=request
)
@api_view(['GET'])
@permission_classes([AllowAny]) # Public endpoint for monitoring
def system_status(request):