Skip to content

Rollback the transaction on error if ATOMIC_REQUESTS is set. #2887

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 2, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions rest_framework/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import unicode_literals
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
from django.db import connection, transaction
from django.utils.encoding import force_text
from django.utils.six.moves.urllib.parse import urlparse as _urlparse
from django.utils import six
Expand Down Expand Up @@ -266,3 +267,19 @@ def apply_markdown(text):
from django.utils.duration import duration_string
else:
DurationField = duration_string = parse_duration = None


def set_rollback():
if hasattr(transaction, 'set_rollback'):
if connection.settings_dict.get('ATOMIC_REQUESTS', False):
# If running in >=1.6 then mark a rollback as required,
# and allow it to be handled by Django.
transaction.set_rollback(True)
elif transaction.is_managed():
# Otherwise handle it explicitly if in managed mode.
if transaction.is_dirty():
transaction.rollback()
transaction.leave_transaction_management()
else:
# transaction not managed
pass
7 changes: 6 additions & 1 deletion rest_framework/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status, exceptions
from rest_framework.compat import HttpResponseBase, View
from rest_framework.compat import HttpResponseBase, View, set_rollback
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.settings import api_settings
Expand Down Expand Up @@ -71,16 +71,21 @@ def exception_handler(exc, context):
else:
data = {'detail': exc.detail}

set_rollback()
return Response(data, status=exc.status_code, headers=headers)

elif isinstance(exc, Http404):
msg = _('Not found.')
data = {'detail': six.text_type(msg)}

set_rollback()
return Response(data, status=status.HTTP_404_NOT_FOUND)

elif isinstance(exc, PermissionDenied):
msg = _('Permission denied.')
data = {'detail': six.text_type(msg)}

set_rollback()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if it'd make more sense to instead have this behavior once, immediately after the point at which the exception handler is called by the view?

That'd ensure that custom exception handlers don't need to deal with rollbacks explicitly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, but since exception_handler is hook-able through settings.EXCEPTION_HANDLER, it allows end-users to control transactional behavior.
Just an idea.

return Response(data, status=status.HTTP_403_FORBIDDEN)

# Note: Unhandled exceptions will raise a 500 error.
Expand Down
110 changes: 110 additions & 0 deletions tests/test_atomic_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import unicode_literals

from django.db import connection, connections, transaction
from django.test import TestCase
from django.utils.unittest import skipUnless
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from tests.models import BasicModel


factory = APIRequestFactory()


class BasicView(APIView):
def post(self, request, *args, **kwargs):
BasicModel.objects.create()
return Response({'method': 'GET'})


class ErrorView(APIView):
def post(self, request, *args, **kwargs):
BasicModel.objects.create()
raise Exception


class APIExceptionView(APIView):
def post(self, request, *args, **kwargs):
BasicModel.objects.create()
raise APIException


@skipUnless(connection.features.uses_savepoints,
"'atomic' requires transactions and savepoints.")
class DBTransactionTests(TestCase):
def setUp(self):
self.view = BasicView.as_view()
connections.databases['default']['ATOMIC_REQUESTS'] = True

def tearDown(self):
connections.databases['default']['ATOMIC_REQUESTS'] = False

def test_no_exception_conmmit_transaction(self):
request = factory.post('/')

with self.assertNumQueries(1):
response = self.view(request)
self.assertFalse(transaction.get_rollback())
self.assertEqual(response.status_code, status.HTTP_200_OK)
assert BasicModel.objects.count() == 1


@skipUnless(connection.features.uses_savepoints,
"'atomic' requires transactions and savepoints.")
class DBTransactionErrorTests(TestCase):
def setUp(self):
self.view = ErrorView.as_view()
connections.databases['default']['ATOMIC_REQUESTS'] = True

def tearDown(self):
connections.databases['default']['ATOMIC_REQUESTS'] = False

def test_generic_exception_delegate_transaction_management(self):
"""
Transaction is eventually managed by outer-most transaction atomic
block. DRF do not try to interfere here.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not immediately sure what this means - would the test be different if we were using the test client, rather than calling the view directly with the request?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, views are wrapped by django.core.handlers.base.make_view_atomic(view) if one of the database defines ATOMIC_REQUESTS to True.

https://github.com/django/django/blob/master/django/core/handlers/base.py#L75

In the context of the test, I just check that drf's exception_handler doesn't intefere and let
django.core.handlers.base.make_view_atomic(view) do the job because the exception is raised.


We let django deal with the transaction when it will catch the Exception.
"""
request = factory.post('/')
with self.assertNumQueries(3):
# 1 - begin savepoint
# 2 - insert
# 3 - release savepoint
with transaction.atomic():
self.assertRaises(Exception, self.view, request)
self.assertFalse(transaction.get_rollback())
assert BasicModel.objects.count() == 1


@skipUnless(connection.features.uses_savepoints,
"'atomic' requires transactions and savepoints.")
class DBTransactionAPIExceptionTests(TestCase):
def setUp(self):
self.view = APIExceptionView.as_view()
connections.databases['default']['ATOMIC_REQUESTS'] = True

def tearDown(self):
connections.databases['default']['ATOMIC_REQUESTS'] = False

def test_api_exception_rollback_transaction(self):
"""
Transaction is rollbacked by our transaction atomic block.
"""
request = factory.post('/')
num_queries = (4 if getattr(connection.features,
'can_release_savepoints', False) else 3)
with self.assertNumQueries(num_queries):
# 1 - begin savepoint
# 2 - insert
# 3 - rollback savepoint
# 4 - release savepoint (django>=1.8 only)
with transaction.atomic():
response = self.view(request)
self.assertTrue(transaction.get_rollback())
self.assertEqual(response.status_code,
status.HTTP_500_INTERNAL_SERVER_ERROR)
assert BasicModel.objects.count() == 0
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ envlist =
{py27,py32,py33,py34}-django{17,18,master}

[testenv]
commands = ./runtests.py --fast
commands = ./runtests.py --fast {posargs}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what this is for - won't block me for merging.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows positional arguments to be passed from running tox <...> on the command line. It's a valid change. Not properly part of this PR, but fine all the same. 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xordoquy
Possible use case: stop tests execution after the first error encountered for a specific tox environment.

$ tox -e py34-django18 -- -x

here -x is an argument intended for py.test

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure i was just wondering how this related to the pull request
Don't get me wrong i'm fine with it

setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
Expand Down
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy