From ca46e660240376b2983feddffcd812d6921dddf0 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 27 Sep 2026 22:06:34 +0330 Subject: [PATCH] keep the login code when Kavenegar answers late instead of failing the request Seen live on 2026-09-27: Kavenegar took 5.4 s, our 5 s timeout fired, the API answered 503 and deleted the code, but the SMS arrived, so the site said "service unavailable" while the phone held a code that no longer worked. A read timeout now means "delivery unknown": the code is kept and the request succeeds (the cooldown still blocks a second SMS). A failed connection is still an explicit 503. Read timeout raised to 10 s. Co-Authored-By: Claude Opus 5.5 --- accounts/services.py | 27 ++++++++++++++++++++++++++- accounts/tests_otp_limits.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/accounts/services.py b/accounts/services.py index 1744d71..d55b88a 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -42,6 +42,14 @@ class SMSDeliveryError(APIException): default_code = 'sms_delivery_failed' +class SMSDeliveryUncertain(SMSDeliveryError): + """ + درخواست به کاوه‌نگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد. + ۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و + کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمی‌کرد. + """ + + class OTPDeliveryError(SMSDeliveryError): """ ⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ می‌کرد و همیشه @@ -95,6 +103,11 @@ def send_verify_lookup(*, phone: str, template: str, token: str) -> None: ) +# کاوه‌نگار گاهی بیش از ۵ ثانیه طول می‌کشد (۵٫۴ ثانیه در ۲۰۲۶-۰۹-۲۷)؛ workerهای +# gunicorn مهلتِ ۱۲۰ ثانیه دارند و gthread است، پس ۱۰ ثانیه کسی را قفل نمی‌کند. +SMS_READ_TIMEOUT_SECONDS = 10 + + def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None: """فراخوانیِ مشترکِ کاوه‌نگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است.""" if not settings.KAVENEGAR_API_KEY: @@ -108,8 +121,15 @@ def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None: response = requests.post( f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}', data=payload, - timeout=5, + # (اتصال، خواندن). وصل‌نشدن یعنی قطعاً نرفت؛ دیر رسیدنِ جواب یعنی نامعلوم. + timeout=(3.05, SMS_READ_TIMEOUT_SECONDS), ) + except requests.ReadTimeout: + logger.warning( + 'SMS provider reply timed out after the request was sent; delivery unknown: ' + 'phone_suffix=%s method=%s', _phone_suffix(phone), method, + ) + raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.') except requests.RequestException: logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone)) raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') @@ -181,6 +201,11 @@ def send_otp_sms(*, phone: str, code: str) -> None: send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code) else: send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code)) + except SMSDeliveryUncertain: + # کد نگه داشته می‌شود و پاسخ موفق است: اگر پیامک رسیده، همان کد کار می‌کند؛ + # اگر نرسیده، کاربر بعد از cooldown کدِ تازه می‌خواهد. برعکسش (۵۰۳ و پاک کردنِ + # کد) کاربری را که پیامک گرفته قفل می‌کرد. cooldown جلوی دو پیامک را می‌گیرد. + return except SMSDeliveryError as exc: raise OTPDeliveryError(str(exc)) from exc diff --git a/accounts/tests_otp_limits.py b/accounts/tests_otp_limits.py index 10da464..dd73364 100644 --- a/accounts/tests_otp_limits.py +++ b/accounts/tests_otp_limits.py @@ -164,3 +164,33 @@ class VerifyLookupTests(TestCase): with patch('accounts.services.requests.post', return_value=_kavenegar_reply(424, 424)): self.assertEqual(_request('09121113303').status_code, 503) self.assertFalse(OTPCode.objects.filter(phone='09121113303').exists()) + + +@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k', KAVENEGAR_OTP_TEMPLATE='SMSVerify', TEST_LOGIN_PHONES={}) +class SlowProviderTests(TestCase): + """۲۰۲۶-۰۹-۲۷: کاوه‌نگار ۵٫۴ ثانیه طول کشید، پیامک رسید، ولی ما ۵۰۳ دادیم و کد را پاک کردیم.""" + + def setUp(self): + cache.clear() + + def test_late_reply_keeps_the_code_and_moves_the_user_on(self): + with patch('accounts.views.secrets.randbelow', return_value=135790), \ + patch('accounts.services.requests.post', side_effect=requests.ReadTimeout('slow')): + self.assertEqual(_request('09121114401').status_code, 200) + self.assertTrue(OTPCode.objects.filter(phone='09121114401', code='135790', is_used=False).exists()) + login = APIClient().post('/api/auth/login', {'phone': '09121114401', 'otp': '135790'}, format='json') + self.assertEqual(login.status_code, 200) + # cooldown سرِ جایش است، پس دکمه‌ی دوباره پیامکِ دوم نمی‌فرستد + with patch('accounts.services.requests.post') as post: + self.assertEqual(_request('09121114401').status_code, 429) + post.assert_not_called() + + def test_no_connection_is_still_an_explicit_503(self): + with patch('accounts.services.requests.post', side_effect=requests.ConnectTimeout('down')): + self.assertEqual(_request('09121114402').status_code, 503) + self.assertFalse(OTPCode.objects.filter(phone='09121114402').exists()) + + def test_read_timeout_is_ten_seconds(self): + with patch('accounts.services.requests.post', return_value=_kavenegar_reply()) as post: + _request('09121114403') + self.assertEqual(post.call_args.kwargs['timeout'], (3.05, 10))