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
intransaction.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
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.See docs.
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 theexcept
-block to call your log function and exit the request without performing any other operations.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:
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.