PEP 484 type stubs for Django REST Framework enabling static type checking with comprehensive type definitions for all major DRF components
—
Django REST Framework provides a comprehensive exception handling system with typed error responses and HTTP status codes. The type stubs ensure type-safe exception handling, error detail formatting, and status code management.
class APIException(Exception):
"""Base class for all REST framework exceptions."""
status_code: int
default_detail: str | dict[str, Any] | list[Any]
default_code: str
detail: Any
def __init__(
self,
detail: str | dict[str, Any] | list[Any] | None = None,
code: str | None = None
) -> None: ...
def get_codes(self) -> str | dict[str, Any] | list[Any]:
"""
Return error codes for the exception.
Returns:
str | dict[str, Any] | list[Any]: Error codes structure
"""
...
def get_full_details(self) -> dict[str, Any] | list[dict[str, Any]]:
"""
Return full error details including codes and messages.
Returns:
dict[str, Any] | list[dict[str, Any]]: Complete error information
"""
...
def __str__(self) -> str: ...class ValidationError(APIException):
"""Exception raised when serializer validation fails."""
status_code: int # 400
default_code: str # 'invalid'
def __init__(
self,
detail: str | dict[str, Any] | list[Any] | None = None,
code: str | None = None
) -> None: ...
class ParseError(APIException):
"""Exception raised when request parsing fails."""
status_code: int # 400
default_detail: str # 'Malformed request.'
default_code: str # 'parse_error'class AuthenticationFailed(APIException):
"""Exception raised when authentication fails."""
status_code: int # 401
default_detail: str # 'Incorrect authentication credentials.'
default_code: str # 'authentication_failed'
class NotAuthenticated(APIException):
"""Exception raised when authentication is required but not provided."""
status_code: int # 401
default_detail: str # 'Authentication credentials were not provided.'
default_code: str # 'not_authenticated'class PermissionDenied(APIException):
"""Exception raised when permission check fails."""
status_code: int # 403
default_detail: str # 'You do not have permission to perform this action.'
default_code: str # 'permission_denied'class NotFound(APIException):
"""Exception raised when a requested resource is not found."""
status_code: int # 404
default_detail: str # 'Not found.'
default_code: str # 'not_found'
class MethodNotAllowed(APIException):
"""Exception raised when HTTP method is not allowed."""
status_code: int # 405
default_detail: str # 'Method "{method}" not allowed.'
default_code: str # 'method_not_allowed'
def __init__(self, method: str, detail: str | None = None, code: str | None = None) -> None: ...
class NotAcceptable(APIException):
"""Exception raised when requested content type cannot be satisfied."""
status_code: int # 406
default_detail: str # 'Could not satisfy the request Accept header.'
default_code: str # 'not_acceptable'
class UnsupportedMediaType(APIException):
"""Exception raised when request media type is not supported."""
status_code: int # 415
default_detail: str # 'Unsupported media type "{media_type}" in request.'
default_code: str # 'unsupported_media_type'
def __init__(self, media_type: str, detail: str | None = None, code: str | None = None) -> None: ...class Throttled(APIException):
"""Exception raised when request is throttled."""
status_code: int # 429
default_detail: str # 'Request was throttled.'
default_code: str # 'throttled'
extra_detail_singular: str
extra_detail_plural: str
def __init__(self, wait: float | None = None, detail: str | None = None, code: str | None = None) -> None: ...Parameters:
wait: float | None - Number of seconds until throttling expiresclass ErrorDetail(str):
"""Enhanced string that includes an error code."""
code: str | None
def __new__(cls, string: str, code: str | None = None) -> ErrorDetail: ...
def __eq__(self, other: Any) -> bool: ...
def __ne__(self, other: Any) -> bool: ...
def __repr__(self) -> str: ...
def __hash__(self) -> int: ...# Complex type definitions for error structures
_Detail = Union[
str,
ErrorDetail,
dict[str, Any],
list[Any]
]
_APIExceptionInput = Union[
str,
dict[str, Any],
list[Any],
ErrorDetail
]
_ErrorCodes = Union[
str,
dict[str, Any],
list[Any]
]
_ErrorFullDetails = Union[
dict[str, Any],
list[dict[str, Any]]
]def _get_error_details(
data: _APIExceptionInput,
default_code: str | None = None
) -> _Detail:
"""
Convert exception input to structured error details.
Args:
data: Raw error data
default_code: Default error code if none provided
Returns:
_Detail: Structured error details
"""
...
def _get_codes(detail: _Detail) -> _ErrorCodes:
"""
Extract error codes from error details.
Args:
detail: Error details structure
Returns:
_ErrorCodes: Error codes structure
"""
...
def _get_full_details(detail: _Detail) -> _ErrorFullDetails:
"""
Get full error details including codes and messages.
Args:
detail: Error details structure
Returns:
_ErrorFullDetails: Complete error information
"""
...def server_error(
request: HttpRequest | Request,
*args: Any,
**kwargs: Any
) -> JsonResponse:
"""
Handle 500 server errors.
Args:
request: Current request object
*args: Additional arguments
**kwargs: Additional keyword arguments
Returns:
JsonResponse: JSON error response
"""
...
def bad_request(
request: HttpRequest | Request,
exception: Exception,
*args: Any,
**kwargs: Any
) -> JsonResponse:
"""
Handle 400 bad request errors.
Args:
request: Current request object
exception: Exception that caused the error
*args: Additional arguments
**kwargs: Additional keyword arguments
Returns:
JsonResponse: JSON error response
"""
...# Informational responses
HTTP_100_CONTINUE: Literal[100]
HTTP_101_SWITCHING_PROTOCOLS: Literal[101]
HTTP_102_PROCESSING: Literal[102]
# Success responses
HTTP_200_OK: Literal[200]
HTTP_201_CREATED: Literal[201]
HTTP_202_ACCEPTED: Literal[202]
HTTP_203_NON_AUTHORITATIVE_INFORMATION: Literal[203]
HTTP_204_NO_CONTENT: Literal[204]
HTTP_205_RESET_CONTENT: Literal[205]
HTTP_206_PARTIAL_CONTENT: Literal[206]
HTTP_207_MULTI_STATUS: Literal[207]
HTTP_208_ALREADY_REPORTED: Literal[208]
HTTP_226_IM_USED: Literal[226]
# Redirection responses
HTTP_300_MULTIPLE_CHOICES: Literal[300]
HTTP_301_MOVED_PERMANENTLY: Literal[301]
HTTP_302_FOUND: Literal[302]
HTTP_303_SEE_OTHER: Literal[303]
HTTP_304_NOT_MODIFIED: Literal[304]
HTTP_305_USE_PROXY: Literal[305]
HTTP_307_TEMPORARY_REDIRECT: Literal[307]
HTTP_308_PERMANENT_REDIRECT: Literal[308]
# Client error responses
HTTP_400_BAD_REQUEST: Literal[400]
HTTP_401_UNAUTHORIZED: Literal[401]
HTTP_402_PAYMENT_REQUIRED: Literal[402]
HTTP_403_FORBIDDEN: Literal[403]
HTTP_404_NOT_FOUND: Literal[404]
HTTP_405_METHOD_NOT_ALLOWED: Literal[405]
HTTP_406_NOT_ACCEPTABLE: Literal[406]
HTTP_407_PROXY_AUTHENTICATION_REQUIRED: Literal[407]
HTTP_408_REQUEST_TIMEOUT: Literal[408]
HTTP_409_CONFLICT: Literal[409]
HTTP_410_GONE: Literal[410]
HTTP_411_LENGTH_REQUIRED: Literal[411]
HTTP_412_PRECONDITION_FAILED: Literal[412]
HTTP_413_REQUEST_ENTITY_TOO_LARGE: Literal[413]
HTTP_414_REQUEST_URI_TOO_LONG: Literal[414]
HTTP_415_UNSUPPORTED_MEDIA_TYPE: Literal[415]
HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE: Literal[416]
HTTP_417_EXPECTATION_FAILED: Literal[417]
HTTP_418_IM_A_TEAPOT: Literal[418]
HTTP_422_UNPROCESSABLE_ENTITY: Literal[422]
HTTP_423_LOCKED: Literal[423]
HTTP_424_FAILED_DEPENDENCY: Literal[424]
HTTP_426_UPGRADE_REQUIRED: Literal[426]
HTTP_428_PRECONDITION_REQUIRED: Literal[428]
HTTP_429_TOO_MANY_REQUESTS: Literal[429]
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE: Literal[431]
HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS: Literal[451]
# Server error responses
HTTP_500_INTERNAL_SERVER_ERROR: Literal[500]
HTTP_501_NOT_IMPLEMENTED: Literal[501]
HTTP_502_BAD_GATEWAY: Literal[502]
HTTP_503_SERVICE_UNAVAILABLE: Literal[503]
HTTP_504_GATEWAY_TIMEOUT: Literal[504]
HTTP_505_HTTP_VERSION_NOT_SUPPORTED: Literal[505]
HTTP_507_INSUFFICIENT_STORAGE: Literal[507]
HTTP_508_LOOP_DETECTED: Literal[508]
HTTP_510_NOT_EXTENDED: Literal[510]
HTTP_511_NETWORK_AUTHENTICATION_REQUIRED: Literal[511]def is_informational(code: int) -> bool:
"""Check if status code is informational (1xx)."""
...
def is_success(code: int) -> bool:
"""Check if status code indicates success (2xx)."""
...
def is_redirect(code: int) -> bool:
"""Check if status code indicates redirection (3xx)."""
...
def is_client_error(code: int) -> bool:
"""Check if status code indicates client error (4xx)."""
...
def is_server_error(code: int) -> bool:
"""Check if status code indicates server error (5xx)."""
...from rest_framework import exceptions, status
from rest_framework.views import APIView
from rest_framework.response import Response
class BookDetailView(APIView):
"""Demonstrate basic exception handling."""
def get(self, request: Request, pk: int) -> Response:
"""Retrieve book with proper exception handling."""
try:
book = Book.objects.get(pk=pk)
except Book.DoesNotExist:
raise exceptions.NotFound("Book not found")
# Check permissions
if not self.has_permission(request, book):
raise exceptions.PermissionDenied("Access denied to this book")
serializer = BookSerializer(book)
return Response(serializer.data)
def put(self, request: Request, pk: int) -> Response:
"""Update book with validation error handling."""
try:
book = Book.objects.get(pk=pk)
except Book.DoesNotExist:
raise exceptions.NotFound("Book not found")
serializer = BookSerializer(book, data=request.data)
if not serializer.is_valid():
raise exceptions.ValidationError(serializer.errors)
serializer.save()
return Response(serializer.data)
def has_permission(self, request: Request, book: Book) -> bool:
"""Check if user has permission to access book."""
return book.is_public or request.user == book.ownerclass BookNotAvailableError(exceptions.APIException):
"""Custom exception for unavailable books."""
status_code = status.HTTP_409_CONFLICT
default_detail = 'Book is currently not available'
default_code = 'book_not_available'
class QuotaExceededError(exceptions.APIException):
"""Custom exception for quota limits."""
status_code = status.HTTP_429_TOO_MANY_REQUESTS
default_detail = 'Download quota exceeded'
default_code = 'quota_exceeded'
def __init__(self, limit: int, reset_time: datetime, detail: str | None = None):
if detail is None:
detail = f'Quota limit of {limit} exceeded. Resets at {reset_time.isoformat()}'
super().__init__(detail)
self.limit = limit
self.reset_time = reset_time
class InvalidFileFormatError(exceptions.APIException):
"""Custom exception for file format validation."""
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
default_detail = 'Invalid file format'
default_code = 'invalid_file_format'
def __init__(self, allowed_formats: list[str], received_format: str):
detail = f'Invalid format "{received_format}". Allowed formats: {", ".join(allowed_formats)}'
super().__init__(detail)
self.allowed_formats = allowed_formats
self.received_format = received_formatclass BookDownloadView(APIView):
"""View demonstrating custom exception usage."""
def post(self, request: Request, pk: int) -> Response:
"""Download book with comprehensive error handling."""
# Get book
try:
book = Book.objects.get(pk=pk)
except Book.DoesNotExist:
raise exceptions.NotFound({
'error': 'Book not found',
'code': 'BOOK_NOT_FOUND',
'book_id': pk
})
# Check availability
if not book.is_available:
raise BookNotAvailableError()
# Check user quota
user_downloads = request.user.downloads.filter(
created_at__date=timezone.now().date()
).count()
daily_limit = getattr(request.user, 'daily_download_limit', 10)
if user_downloads >= daily_limit:
tomorrow = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
raise QuotaExceededError(daily_limit, tomorrow)
# Check file format
requested_format = request.data.get('format', 'pdf')
allowed_formats = ['pdf', 'epub', 'mobi']
if requested_format not in allowed_formats:
raise InvalidFileFormatError(allowed_formats, requested_format)
# Process download
download = Download.objects.create(
user=request.user,
book=book,
format=requested_format
)
return Response({
'download_id': download.id,
'download_url': download.get_download_url(),
'expires_at': (timezone.now() + timedelta(hours=24)).isoformat()
}, status=status.HTTP_201_CREATED)from rest_framework import serializers
class BookSerializer(serializers.ModelSerializer):
"""Serializer with detailed validation errors."""
class Meta:
model = Book
fields = ['title', 'author', 'isbn', 'published_date', 'pages']
def validate_isbn(self, value: str) -> str:
"""Validate ISBN with detailed error."""
if len(value) not in [10, 13]:
raise serializers.ValidationError(
detail='ISBN must be 10 or 13 characters long',
code='invalid_length'
)
# Check if ISBN already exists
if Book.objects.filter(isbn=value).exists():
raise serializers.ValidationError(
detail='Book with this ISBN already exists',
code='duplicate_isbn'
)
return value
def validate_pages(self, value: int) -> int:
"""Validate page count."""
if value <= 0:
raise serializers.ValidationError(
detail='Page count must be positive',
code='invalid_page_count'
)
if value > 10000:
raise serializers.ValidationError(
detail='Page count exceeds maximum limit of 10,000',
code='page_count_too_high'
)
return value
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
"""Cross-field validation."""
published_date = data.get('published_date')
if published_date and published_date > timezone.now().date():
raise serializers.ValidationError({
'published_date': ErrorDetail(
'Published date cannot be in the future',
code='future_date'
)
})
return datafrom rest_framework.views import exception_handler as drf_exception_handler
from rest_framework.response import Response
import logging
logger = logging.getLogger(__name__)
def custom_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
"""Custom exception handler with enhanced error formatting."""
# Get the standard error response
response = drf_exception_handler(exc, context)
if response is not None:
# Log the error
request = context.get('request')
view = context.get('view')
logger.error(
f"API Error: {exc.__class__.__name__} - {str(exc)} - "
f"View: {view.__class__.__name__ if view else 'Unknown'} - "
f"User: {getattr(request, 'user', 'Anonymous') if request else 'Unknown'} - "
f"Path: {getattr(request, 'path', 'Unknown') if request else 'Unknown'}"
)
# Customize error response format
custom_response_data = {
'success': False,
'error': {
'type': exc.__class__.__name__,
'message': str(exc),
'status_code': response.status_code,
'timestamp': timezone.now().isoformat(),
}
}
# Add detailed error information for validation errors
if isinstance(exc, ValidationError) and hasattr(exc, 'detail'):
custom_response_data['error']['details'] = exc.detail
# Add field-specific error codes
if hasattr(exc, 'get_codes'):
custom_response_data['error']['codes'] = exc.get_codes()
# Add request context in debug mode
if settings.DEBUG and request:
custom_response_data['debug'] = {
'request_method': request.method,
'request_path': request.path,
'user': str(request.user) if hasattr(request, 'user') else 'Unknown',
'view_name': view.__class__.__name__ if view else 'Unknown',
}
response.data = custom_response_data
return response
# Configure in settings.py:
# REST_FRAMEWORK = {
# 'EXCEPTION_HANDLER': 'myapp.exceptions.custom_exception_handler'
# }class ExceptionResponseMiddleware:
"""Middleware for consistent exception response formatting."""
def __init__(self, get_response: Callable) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
return response
def process_exception(self, request: HttpRequest, exception: Exception) -> JsonResponse | None:
"""Handle exceptions not caught by DRF."""
if not request.path.startswith('/api/'):
return None # Let Django handle non-API requests
# Handle specific exception types
if isinstance(exception, ValueError):
return JsonResponse({
'success': False,
'error': {
'type': 'ValueError',
'message': 'Invalid input data',
'status_code': 400,
'timestamp': timezone.now().isoformat()
}
}, status=400)
elif isinstance(exception, PermissionError):
return JsonResponse({
'success': False,
'error': {
'type': 'PermissionError',
'message': 'Permission denied',
'status_code': 403,
'timestamp': timezone.now().isoformat()
}
}, status=403)
# Handle unexpected exceptions
logger.exception(f"Unhandled exception in API: {exception}")
if not settings.DEBUG:
return JsonResponse({
'success': False,
'error': {
'type': 'InternalServerError',
'message': 'An unexpected error occurred',
'status_code': 500,
'timestamp': timezone.now().isoformat()
}
}, status=500)
return None # Let Django's debug handler take over in debug modefrom rest_framework.test import APITestCase
from rest_framework import status
class ExceptionHandlingTests(APITestCase):
"""Test exception handling behavior."""
def test_not_found_exception(self) -> None:
"""Test 404 exception handling."""
response = self.client.get('/api/books/999/')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertIn('error', response.data)
self.assertEqual(response.data['error']['type'], 'NotFound')
def test_validation_exception(self) -> None:
"""Test validation error handling."""
invalid_data = {
'title': '', # Empty title should fail validation
'isbn': '123', # Invalid ISBN length
}
response = self.client.post('/api/books/', invalid_data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('details', response.data['error'])
self.assertIn('title', response.data['error']['details'])
self.assertIn('isbn', response.data['error']['details'])
def test_permission_exception(self) -> None:
"""Test permission denied handling."""
# Create a private book owned by another user
other_user = User.objects.create_user('other', 'other@example.com', 'password')
book = Book.objects.create(title='Private Book', owner=other_user, is_private=True)
response = self.client.get(f'/api/books/{book.id}/')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.data['error']['type'], 'PermissionDenied')
def test_custom_exception(self) -> None:
"""Test custom exception handling."""
# Create scenario that triggers custom exception
book = Book.objects.create(title='Test Book', is_available=False)
response = self.client.post(f'/api/books/{book.id}/download/', {'format': 'pdf'})
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(response.data['error']['type'], 'BookNotAvailableError')This comprehensive exception and status system provides type-safe error handling with full mypy support, enabling confident implementation of robust error management and HTTP status code handling in Django REST Framework applications.
Install with Tessl CLI
npx tessl i tessl/pypi-djangorestframework-stubs