Files
clinicpro/.claude/prompt/fix-kavenegar-sms-errors.md

10 KiB

رفع خطاهای ارسال پیامک کاوه‌نگار در سرور prod (431 + Idle timeout)

پروژه

clinicpro (backend — SMS provider)

زمینه

ارسال پیامک از طریق کاوه‌نگار در سرور prod دو خطای متناوب می‌دهد (لوکال/ddev سالم است). هر دو روی همان endpoint verify/lookup.json رخ می‌دهند:

مسیر: /api/v1/representation/clinic
SMS sendTemplate failed (kavenegar): HTTP/1.1 431 Request Header Fields Too Large
  returned for "https://api.kavenegar.com/v1/<APIKEY>/verify/lookup.json".
  @ vendor/symfony/http-client/Response/CommonResponseTrait.php:170
context {"mobile":"09398631203","template":"clinicpro-welcome"}

مسیر: /api/v1/user/send-code
SMS sendTemplate failed (kavenegar): Idle timeout reached
  for "https://api.kavenegar.com/v1/<APIKEY>/verify/lookup.json".
  @ vendor/symfony/http-client/Chunk/ErrorChunk.php:55
context {"mobile":"09175415545","template":"clinicpro-otp"}

خطاها در KavehNegarProvider::sendTemplate() گرفته و log می‌شوند؛ متد false برمی‌گرداند، پس پیامک بی‌صدا شکست می‌خورد (welcome و OTP ارسال نمی‌شوند). ارسال به‌صورت async از طریق messenger (SendSmsHandler) انجام می‌شود.

مشکل / هدف

هر دو خطا در لایهٔ شبکه/edge بین کانتینر prod و api.kavenegar.com هستند، نه باگ منطقی:

  • 431 Request Header Fields Too Large — سرور/واسط (proxy یا edge) هدرهای درخواست را بیش از حد بزرگ می‌بیند. مظنون اصلی: وجود متغیر محیطی HTTP_PROXY/HTTPS_PROXY روی هاست Coolify که Symfony HttpClient به‌صورت خودکار رعایت می‌کند و درخواست کاوه‌نگار را از یک proxy معیوب رد می‌کند؛ آن proxy هدر اضافه تزریق/بزرگ می‌کند → 431.
  • Idle timeout reached — curl برای POST body هدر Expect: 100-continue می‌فرستد؛ برخی proxy/edgeها به آن پاسخ 100 نمی‌دهند و اتصال تا سقف idle معطل می‌ماند → timeout. timeout فعلی هم فقط ۱۰ ثانیه است و بدون retry.

هدف: (۱) تشخیص و رفع منشأ محیطی (proxy)، (۲) سخت‌سازی کد provider تا در برابر این دو حالت مقاوم شود.

فایل‌های مرتبط

فایل نقش
src/Sms/Provider/KavehNegarProvider.php ساخت درخواست HTTP به کاوه‌نگار (send + sendTemplate)
src/Sms/Service/SendSmsHandler.php هندلر async messenger که provider را صدا می‌زند
config/services.yaml (خطوط ~95) wiring پارامترهای provider ($apiKey, $sender)
Dockerfile / محیط Coolify محل احتمالی متغیر proxy

وضعیت فعلی

KavehNegarProvider::sendTemplate() (خطوط ۷۶–۸۱) — درخواست بدون هدر صریح، body رشتهٔ از پیش encode شده، timeout ثابت، بدون retry:

$resp = $this->httpClient->request('POST',
    self::BASE . '/' . $this->key() . '/verify/lookup.json', [
        'body' => http_build_query($params),
        'timeout' => 10,
    ]
);

متد send() (خطوط ۲۸–۳۷) هم همان الگو را دارد.

وظایف

۱. تشخیص و رفع منشأ محیطی (proxy) — اول این

روی سرور prod (داخل کانتینر) بررسی کن آیا proxy ست شده و آیا کاوه‌نگار مستقیم در دسترس است:

# داخل کانتینر prod
printenv | grep -iE 'proxy'          # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY ?
curl -sS -o /dev/null -w '%{http_code}\n' \
  --data 'test=1' https://api.kavenegar.com/v1/<APIKEY>/account/info.json
  • اگر متغیر proxy وجود دارد و لازم نیست → آن را از تنظیمات Coolify/کانتینر حذف کن، یا api.kavenegar.com را به NO_PROXY اضافه کن.
  • اگر proxy لازم است ولی معیوب است → در سطح کد با گزینهٔ 'proxy' => null یا 'no_proxy' درخواست کاوه‌نگار را از آن مستثنا کن (وظیفهٔ ۲).
  • اگر curl مستقیم هم fail شد → مشکل دسترسی شبکه/whitelist IP سمت کاوه‌نگار است، نه کد؛ IP سرور prod باید در پنل کاوه‌نگار مجاز شود.

۲. تغییر متد به GET با query params (مطابق مستند رسمی کاوه‌نگار) — راه‌حل اصلی و اجباری

الزام قطعی: همه درخواست‌های KavehNegarProvider به کاوه‌نگار — بدون استثنا — باید به‌صورت GET با query string ارسال شوند. هیچ درخواست POST یا body مجاز نیست. این شامل هر متد فعلی (send, sendTemplate) و هر متد/endpoint جدیدی است که در آینده به این provider اضافه شود. الگوی POST body باید کاملاً حذف شود.

مستند رسمی کاوه‌نگار همهٔ درخواست‌ها را به‌صورت GET با query string نشان می‌دهد، نه POST body:

درخواست:
https://api.kavenegar.com/v1/{API-KEY}/verify/lookup.json?receptor=09*********&token=852596&template=myverification

پاسخ:
{
    "return": { "status": 200, "message": "تایید شد" },
    "entries": [
        {
            "messageid": 8792343,
            "message": "ممنون از ثبت نام شما کد تایید عضویت : 852596",
            "status": 5,
            "statustext": "ارسال به مخابرات",
            "sender": "10004346",
            "receptor": "09*********",
            "date": 1356619709,
            "cost": 120
        }
    ]
}

هر دو متد send() و sendTemplate() باید به این شکل ارسال شوند — GET با پارامترها در query، بدون POST body. این مستقیماً علت idle timeout را رفع می‌کند چون GET بدنه ندارد پس curl هدر Expect: 100-continue نمی‌فرستد، و حجم درخواست کوچک می‌ماند.

// sendTemplate — GET با query به‌جای POST body
$resp = $this->httpClient->request('GET',
    self::BASE . '/' . $this->key() . '/verify/lookup.json', [
        'query'        => $params,   // receptor, template, token... → به‌صورت query string
        'timeout'      => 15,        // idle timeout هر chunk
        'max_duration' => 30,        // سقف کل درخواست
        'proxy'        => null,      // بایپس proxy محیطی برای کاوه‌نگار (در صورت لزوم طبق وظیفه ۱)
    ]
);
// send — همان الگو با endpoint sms/send.json
$resp = $this->httpClient->request('GET',
    self::BASE . '/' . $this->key() . '/sms/send.json', [
        'query'        => ['receptor' => $mobile, 'message' => $message, 'sender' => $this->sender()],
        'timeout'      => 15,
        'max_duration' => 30,
        'proxy'        => null,
    ]
);

نکات:

  • دیگر نیازی به http_build_query نیست؛ Symfony با query خودش پارامترها را urlencode می‌کند (پیام فارسی و ZWNJ در message/token درست encode می‌شوند).
  • منطق slot mapping (forSlot, NO_SPACE_SLOTS, ساخت $params) دست‌نخورده بماند — فقط نحوهٔ ارسال از POST body به GET query تغییر کند.
  • پاسخ همان ساختار return.status است؛ منطق ($data['return']['status'] ?? 0) === 200 بدون تغییر کار می‌کند.

۳. افزودن retry برای خطاهای گذرا

431/timeout گاهی گذرا هستند. یک retry کوتاه اضافه کن. دو گزینه:

  • ساده (درون‌متدی): حلقهٔ ۲–۳ تلاش با usleep کوتاه بین تلاش‌ها، فقط برای TransportExceptionInterface/TimeoutException.
  • تمیزتر (Symfony): provider را با RetryableHttpClient بپیچ (در services.yaml یک decorator بساز و به $httpClient این سرویس تزریق کن) تا retry با backoff نمایی و jitter به‌صورت استاندارد انجام شود.

الگوی موجود پروژه ساده‌گرا است؛ گزینهٔ درون‌متدی کافی است مگر اینکه retry برای providerهای دیگر هم لازم شود.

نکات مهم

  • منشأ اصلی محیطی است (prod فقط)؛ اول وظیفهٔ ۱ را اجرا کن — اگر proxy معیوب حذف شود ممکن است وظایف ۲/۳ صرفاً hardening باشند نه رفع اصلی.
  • API key در مسیر URL است نه هدر؛ پس 431 از حجم هدرهای برنامه نیست — مؤید دخالت یک واسط (proxy/edge) است.
  • ارسال async است (SendSmsHandler روی messenger async)؛ اگر messenger retry فعال است، شکست فعلی احتمالاً چند بار retry شده و بعد به failed transport رفته — بعد از fix، صف failed را بررسی/پاک کن (messenger:failed:show).
  • credentials کاوه‌نگار را در هیچ log یا خروجی چاپ نکن؛ در دستور curl تشخیص، <APIKEY> را دستی جایگزین کن و در تاریخچه شل نگه‌ندار.
  • تغییر API نیست (فقط لایهٔ provider)؛ docs/api/sms.md نیاز به تغییر قرارداد ندارد. اگر رفتار/timeout مستند شده، یک یادداشت کوتاه دربارهٔ retry/timeout اضافه کن.
  • هر دو متد send() و sendTemplate() باید هم‌راستا اصلاح شوند تا پیامک ساده و template یکسان رفتار کنند.
  • اجباری: بعد از تغییر، هیچ ->request('POST', ...) و هیچ کلید 'body' در این provider نباید باقی بماند — همه صرفاً ->request('GET', ..., ['query' => ...]). این را قبل از پایان با grep تأیید کن:
grep -nE "request\('POST'|'body'" src/Sms/Provider/KavehNegarProvider.php   # باید خروجی خالی باشد