skip to Main Content

I have a Django project with ATOMIC_REQUESTS=True.

When authentication fails I need to store critical data for the login attempt, here is example code:

class LoginSerializer(serializers.Serializer):
  ...
  
  def validate(self, data):
    ...
    user = authenticate(email=email, password=password)
    if user is None:
      failed_login_attempt(email)
      raise serializers.ValidationError("Invalid credentials")


def failed_login_attempt(email):
  user = User.objects.get(email=email)
  user.lock_status = "LOCKED"
  user.save()

  Device.objects.create(user=user, ip="127.0.0.1", fingerprint="some-fingerprint")

  Activity.objects.create(user=user, type="failed_login_attempt")

Constraints:

Raising the ValidationError causes all changes (including updates to user.lock_status, Device and Activity) to be rolled back. This behavior is expected because of the global transaction but I need these changes to persist.

I cannot remove ATOMIC_REQUESTS=True as it’s crucial for other parts of the application.

Goal:

I want to ensure that the failed_login_attempt function persists changes even if the ValidationError rolls back the rest of the transaction. What’s the best way to persist those critical changes?

What I’ve tried:

  • Wrapping failed_login_attempt in transaction.atomic() This doesn’t work because they are still part of the outer transaction.

  • Separate database configuration with ATOMIC_REQUESTS=False and custom Database Router as seen in this asnwer, however:

    This required broad permissions for other models (User, Device), which would have allowed writes outside of the global transaction.
    I couldn’t limit this approach to only the specific function (failed_login_attempt), leading to overly broad control.

Bypassing the ORM and using raw SQL connections.cursor() feels like a hack so I would prefer to avoid it.

2

Answers


  1. It seems like you could solve this by using the django.db.transaction.non_atomic_requests decorator on each view where you want to persist changes.

    This decorator will negate the effect of ATOMIC_REQUESTS for a given view.

    See docs.

    Login or Signup to reply.
  2. Preface: Unhandled exceptions trigger transaction failure.

    If it’s only the login-failure you need this behavior for you can wrap the serializer call in a try-block, and then use the except-block to call your log function and exit the request without performing any other operations.

    def view(request):
        serializer_class = LoginSerializer
        
        try:
            serializer_class(request.data).is_valid(raise_exception=True)
            # Do some business operation
        except:
            failed_login_attempt(request.user.email)
            return None
    

    If you have sufficient access & need of it, you can put this modification into your authentication middleware/backend to avoid calling it manually on many/all views.

    The strength of this approach is that it doesn’t require any permissions to operate outside of the transaction – you are specifically handling the exception without causing the transaction to roll back, so the transaction boundary remains intact all throughout.

    The weakness of this approach is that if an exception occurs elsewhere, for any reason, the failure record will be rolled back. If you do nothing but return an empty (or practically empty) response after saving the failure record, the chances of that should be near-zero … but not actually zero, so take that into account.

    Aside from that, you have these choices:

    • Rewrite user authentication to not raise exceptions on failure, find some other ways to handle it
    • Separate database connection + router as your post mentioned
    • Use a task queue with separate workers (APSchedule, Celery, etc.) to write the failure records, they’ll operate outside of the request’s transaction boundary and will thus be unaffected by any rollbacks
    • Abstract the failure-logger to be its own microservice and send the log records over HTTP – those can’t be rolled back by the transaction
    • Use logging to dump the login attempts to file (those can’t be rolled back either). And then optionally ingest the logs periodically to turn them into database records, either manually in your django instance or on a self-hosted thing like an ELK-stack, or through a third-party service like Sentry, Datadog, New Relic, etc.
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search