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روی messengerasync)؛ اگر 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 # باید خروجی خالی باشد