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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,14 @@ class SMSDeliveryError(APIException):
|
|||||||
default_code = 'sms_delivery_failed'
|
default_code = 'sms_delivery_failed'
|
||||||
|
|
||||||
|
|
||||||
|
class SMSDeliveryUncertain(SMSDeliveryError):
|
||||||
|
"""
|
||||||
|
درخواست به کاوهنگار رسید ولی جوابش در مهلت نیامد؛ پیامک شاید رفته باشد.
|
||||||
|
۲۰۲۶-۰۹-۲۷ دیده شد: پاسخ ۵٫۴ ثانیه طول کشید، ما با مهلتِ ۵ ثانیه ۵۰۳ دادیم و
|
||||||
|
کد را پاک کردیم، ولی پیامک رسید — کاربر کدی در دست داشت که دیگر کار نمیکرد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OTPDeliveryError(SMSDeliveryError):
|
class OTPDeliveryError(SMSDeliveryError):
|
||||||
"""
|
"""
|
||||||
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
⚠️ اصلاح: قبلاً 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:
|
def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
|
||||||
"""فراخوانیِ مشترکِ کاوهنگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است."""
|
"""فراخوانیِ مشترکِ کاوهنگار و سنجشِ پاسخ؛ هر شکستی SMSDeliveryError است."""
|
||||||
if not settings.KAVENEGAR_API_KEY:
|
if not settings.KAVENEGAR_API_KEY:
|
||||||
@@ -108,8 +121,15 @@ def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
|
|||||||
response = requests.post(
|
response = requests.post(
|
||||||
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
|
f'https://api.kavenegar.com/v1/{settings.KAVENEGAR_API_KEY}/{method}',
|
||||||
data=payload,
|
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:
|
except requests.RequestException:
|
||||||
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
||||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
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)
|
send_verify_lookup(phone=phone, template=settings.KAVENEGAR_OTP_TEMPLATE, token=code)
|
||||||
else:
|
else:
|
||||||
send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code))
|
send_sms(phone=phone, message=settings.OTP_SMS_TEMPLATE.format(code=code))
|
||||||
|
except SMSDeliveryUncertain:
|
||||||
|
# کد نگه داشته میشود و پاسخ موفق است: اگر پیامک رسیده، همان کد کار میکند؛
|
||||||
|
# اگر نرسیده، کاربر بعد از cooldown کدِ تازه میخواهد. برعکسش (۵۰۳ و پاک کردنِ
|
||||||
|
# کد) کاربری را که پیامک گرفته قفل میکرد. cooldown جلوی دو پیامک را میگیرد.
|
||||||
|
return
|
||||||
except SMSDeliveryError as exc:
|
except SMSDeliveryError as exc:
|
||||||
raise OTPDeliveryError(str(exc)) from exc
|
raise OTPDeliveryError(str(exc)) from exc
|
||||||
|
|
||||||
|
|||||||
@@ -164,3 +164,33 @@ class VerifyLookupTests(TestCase):
|
|||||||
with patch('accounts.services.requests.post', return_value=_kavenegar_reply(424, 424)):
|
with patch('accounts.services.requests.post', return_value=_kavenegar_reply(424, 424)):
|
||||||
self.assertEqual(_request('09121113303').status_code, 503)
|
self.assertEqual(_request('09121113303').status_code, 503)
|
||||||
self.assertFalse(OTPCode.objects.filter(phone='09121113303').exists())
|
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user