diff --git a/djangorestframework/exceptions.py b/djangorestframework/exceptions.py index 3f7e9029c..315c1b1d8 100644 --- a/djangorestframework/exceptions.py +++ b/djangorestframework/exceptions.py @@ -1,9 +1,15 @@ +""" +Handled exceptions raised by REST framework. + +In addition Django's built in 403 and 404 exceptions are handled. +(`django.http.Http404` and `django.core.exceptions.PermissionDenied`) +""" from djangorestframework import status class ParseError(Exception): status_code = status.HTTP_400_BAD_REQUEST - default_detail = 'Malformed request' + default_detail = 'Malformed request.' def __init__(self, detail=None): self.detail = detail or self.default_detail @@ -11,7 +17,7 @@ class ParseError(Exception): class PermissionDenied(Exception): status_code = status.HTTP_403_FORBIDDEN - default_detail = 'You do not have permission to access this resource' + default_detail = 'You do not have permission to access this resource.' def __init__(self, detail=None): self.detail = detail or self.default_detail @@ -19,19 +25,30 @@ class PermissionDenied(Exception): class MethodNotAllowed(Exception): status_code = status.HTTP_405_METHOD_NOT_ALLOWED - default_detail = "Method '%s' not allowed" + default_detail = "Method '%s' not allowed." - def __init__(self, method, detail): + def __init__(self, method, detail=None): self.detail = (detail or self.default_detail) % method class UnsupportedMediaType(Exception): status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE - default_detail = "Unsupported media type '%s' in request" + default_detail = "Unsupported media type '%s' in request." def __init__(self, media_type, detail=None): self.detail = (detail or self.default_detail) % media_type -# class Throttled(Exception): -# def __init__(self, detail): -# self.detail = detail + +class Throttled(Exception): + status_code = status.HTTP_429_TOO_MANY_REQUESTS + default_detail = "Request was throttled. Expected available in %d seconds." + + def __init__(self, wait, detail=None): + import math + self.detail = (detail or self.default_detail) % int(math.ceil(wait)) + + +REST_FRAMEWORK_EXCEPTIONS = ( + ParseError, PermissionDenied, MethodNotAllowed, + UnsupportedMediaType, Throttled +) diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index b56d8a324..bdda4defa 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -6,9 +6,7 @@ Permission behavior is provided by mixing the :class:`mixins.PermissionsMixin` c """ from django.core.cache import cache -from djangorestframework import status -from djangorestframework.exceptions import PermissionDenied -from djangorestframework.response import ImmediateResponse +from djangorestframework.exceptions import PermissionDenied, Throttled import time __all__ = ( @@ -24,11 +22,6 @@ __all__ = ( SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] -_503_SERVICE_UNAVAILABLE = ImmediateResponse( - {'detail': 'request was throttled'}, - status=status.HTTP_503_SERVICE_UNAVAILABLE) - - class BasePermission(object): """ A base class from which all permission classes should inherit. @@ -192,7 +185,7 @@ class BaseThrottle(BasePermission): """ self.history.insert(0, self.now) cache.set(self.key, self.history, self.duration) - header = 'status=SUCCESS; next=%s sec' % self.next() + header = 'status=SUCCESS; next=%.2f sec' % self.next() self.view.headers['X-Throttle'] = header def throttle_failure(self): @@ -200,9 +193,10 @@ class BaseThrottle(BasePermission): Called when a request to the API has failed due to throttling. Raises a '503 service unavailable' response. """ - header = 'status=FAILURE; next=%s sec' % self.next() + wait = self.next() + header = 'status=FAILURE; next=%.2f sec' % wait self.view.headers['X-Throttle'] = header - raise _503_SERVICE_UNAVAILABLE + raise Throttled(wait) def next(self): """ @@ -215,7 +209,7 @@ class BaseThrottle(BasePermission): available_requests = self.num_requests - len(self.history) + 1 - return '%.2f' % (remaining_duration / float(available_requests)) + return remaining_duration / float(available_requests) class PerUserThrottling(BaseThrottle): diff --git a/djangorestframework/response.py b/djangorestframework/response.py index ea9a938c6..ac16e79a9 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -161,25 +161,3 @@ class Response(SimpleTemplateResponse): }, status=status.HTTP_406_NOT_ACCEPTABLE, view=self.view, request=self.request, renderers=[renderer]) - - -class ImmediateResponse(Response, Exception): - """ - An exception representing an Response that should be returned immediately. - Any content should be serialized as-is, without being filtered. - """ - #TODO: this is just a temporary fix, the whole rendering/support for ImmediateResponse, should be remade : see issue #163 - - def render(self): - try: - return super(Response, self).render() - except ImmediateResponse: - renderer, media_type = self._determine_renderer() - self.renderers.remove(renderer) - if len(self.renderers) == 0: - raise RuntimeError('Caught an ImmediateResponse while '\ - 'trying to render an ImmediateResponse') - return self.render() - - def __init__(self, *args, **kwargs): - self.response = Response(*args, **kwargs) diff --git a/djangorestframework/tests/response.py b/djangorestframework/tests/response.py index 07d0f4fbf..ded0a3daa 100644 --- a/djangorestframework/tests/response.py +++ b/djangorestframework/tests/response.py @@ -4,7 +4,7 @@ import unittest from django.conf.urls.defaults import patterns, url, include from django.test import TestCase -from djangorestframework.response import Response, NotAcceptable, ImmediateResponse +from djangorestframework.response import Response, NotAcceptable from djangorestframework.views import View from djangorestframework.compat import RequestFactory from djangorestframework import status diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py index d307cd32d..ad22d2d27 100644 --- a/djangorestframework/tests/throttling.py +++ b/djangorestframework/tests/throttling.py @@ -45,7 +45,7 @@ class ThrottlingTests(TestCase): request = self.factory.get('/') for dummy in range(4): response = MockView.as_view()(request) - self.assertEqual(503, response.status_code) + self.assertEqual(429, response.status_code) def set_throttle_timer(self, view, value): """ @@ -62,7 +62,7 @@ class ThrottlingTests(TestCase): request = self.factory.get('/') for dummy in range(4): response = MockView.as_view()(request) - self.assertEqual(503, response.status_code) + self.assertEqual(429, response.status_code) # Advance the timer by one second self.set_throttle_timer(MockView, 1) @@ -90,7 +90,7 @@ class ThrottlingTests(TestCase): """ Ensure request rate is limited globally per View for PerViewThrottles """ - self.ensure_is_throttled(MockView_PerViewThrottling, 503) + self.ensure_is_throttled(MockView_PerViewThrottling, 429) def ensure_response_header_contains_proper_throttle_field(self, view, expected_headers): """ diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 36d05721c..a7540e0c2 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -6,12 +6,14 @@ By setting or modifying class attributes on your view, you change it's predefine """ import re +from django.core.exceptions import PermissionDenied +from django.http import Http404 from django.utils.html import escape from django.utils.safestring import mark_safe from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View as DjangoView, apply_markdown -from djangorestframework.response import Response, ImmediateResponse +from djangorestframework.response import Response from djangorestframework.request import Request from djangorestframework import renderers, parsers, authentication, permissions, status, exceptions @@ -219,13 +221,27 @@ class View(DjangoView): response[key] = value return response + def handle_exception(self, exc): + """ + Handle any exception that occurs, by returning an appropriate response, + or re-raising the error. + """ + if isinstance(exc, exceptions.REST_FRAMEWORK_EXCEPTIONS): + return Response({'detail': exc.detail}, status=exc.status_code) + elif isinstance(exc, Http404): + return Response({'detail': 'Not found'}, + status=status.HTTP_404_NOT_FOUND) + elif isinstance(exc, PermissionDenied): + return Response({'detail': 'Permission denied'}, + status=status.HTTP_403_FORBIDDEN) + raise + # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): request = Request(request, parsers=self.parsers, authentication=self.authentication) self.request = request - self.args = args self.kwargs = kwargs self.headers = self.default_response_headers @@ -244,10 +260,8 @@ class View(DjangoView): response = handler(request, *args, **kwargs) - except ImmediateResponse, exc: - response = exc.response - except (exceptions.ParseError, exceptions.PermissionDenied) as exc: - response = Response({'detail': exc.detail}, status=exc.status_code) + except Exception as exc: + response = self.handle_exception(exc) self.response = self.final(request, response, *args, **kwargs) return self.response @@ -265,4 +279,4 @@ class View(DjangoView): for name, field in form.fields.iteritems(): field_name_types[name] = field.__class__.__name__ content['fields'] = field_name_types - raise ImmediateResponse(content, status=status.HTTP_200_OK) + raise Response(content, status=status.HTTP_200_OK)