# رفع خطاهای ارسال پیامک کاوه‌نگار در سرور 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//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//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: ```php $resp = $this->httpClient->request('POST', self::BASE . '/' . $this->key() . '/verify/lookup.json', [ 'body' => http_build_query($params), 'timeout' => 10, ] ); ``` متد `send()` (خطوط ۲۸–۳۷) هم همان الگو را دارد. ## وظایف ### ۱. تشخیص و رفع منشأ محیطی (proxy) — اول این روی سرور prod (داخل کانتینر) بررسی کن آیا proxy ست شده و آیا کاوه‌نگار مستقیم در دسترس است: ```bash # داخل کانتینر 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//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` نمی‌فرستد، و حجم درخواست کوچک می‌ماند. ```php // 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 محیطی برای کاوه‌نگار (در صورت لزوم طبق وظیفه ۱) ] ); ``` ```php // 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 تشخیص، `` را دستی جایگزین کن و در تاریخچه شل نگه‌ندار. - تغییر API نیست (فقط لایهٔ provider)؛ `docs/api/sms.md` نیاز به تغییر قرارداد ندارد. اگر رفتار/timeout مستند شده، یک یادداشت کوتاه دربارهٔ retry/timeout اضافه کن. - هر دو متد `send()` و `sendTemplate()` باید هم‌راستا اصلاح شوند تا پیامک ساده و template یکسان رفتار کنند. - **اجباری:** بعد از تغییر، هیچ `->request('POST', ...)` و هیچ کلید `'body'` در این provider نباید باقی بماند — همه صرفاً `->request('GET', ..., ['query' => ...])`. این را قبل از پایان با grep تأیید کن: ```bash grep -nE "request\('POST'|'body'" src/Sms/Provider/KavehNegarProvider.php # باید خروجی خالی باشد ```