From 8f4f7fc95124fbcf423d30a318c0ad6770e20626 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 8 Jul 2026 09:51:30 +0330 Subject: [PATCH] fix: update KavehNegarProvider to use GET requests with query parameters to resolve 431 and idle timeout errors --- .claude/prompt/fix-kavenegar-sms-errors.md | 157 +++++++++++++++++++++ docs/api/sms.md | 1 + src/Sms/Provider/KavehNegarProvider.php | 50 ++++--- tests/Sms/KavehNegarProviderTest.php | 49 +++++++ 4 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 .claude/prompt/fix-kavenegar-sms-errors.md create mode 100644 tests/Sms/KavehNegarProviderTest.php diff --git a/.claude/prompt/fix-kavenegar-sms-errors.md b/.claude/prompt/fix-kavenegar-sms-errors.md new file mode 100644 index 00000000..27d74b48 --- /dev/null +++ b/.claude/prompt/fix-kavenegar-sms-errors.md @@ -0,0 +1,157 @@ +# رفع خطاهای ارسال پیامک کاوه‌نگار در سرور 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 # باید خروجی خالی باشد +``` diff --git a/docs/api/sms.md b/docs/api/sms.md index 57ed1849..b0f44140 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -17,6 +17,7 @@ - شماره فرستنده تنظیم نمی‌شود؛ کاوه‌نگار از خط پیش‌فرض حساب استفاده می‌کند. - endpoint `GET /api/v1/admin/settings` یک فیلد read-only به نام `sms_api_key_configured` (boolean) برمی‌گرداند. - `PATCH /api/v1/admin/settings` کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender` را نمی‌پذیرد (از `ALLOWED_KEYS` حذف شده‌اند). +- **ترابری (transport) کاوه‌نگار:** همه‌ی فراخوانی‌های `KavehNegarProvider` به کاوه‌نگار به‌صورت **GET با query string** ارسال می‌شوند (مطابق مستند رسمی) — هیچ درخواست `POST`/`body` ساخته نمی‌شود. GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمی‌فرستد؛ این جلوی خطاهای `431 Request Header Fields Too Large` و `Idle timeout` را که در prod دیده می‌شد می‌گیرد. هر درخواست `timeout=15s`، `max_duration=30s`، بایپس proxy محیطی (`proxy=null`) و تا **۳ بار retry** با backoff برای خطاهای گذرای شبکه دارد. - **قیمت هر پیامک** از کلید تنظیمات `sms_price_rials` خوانده می‌شود (قابل ویرایش در `/admin/settings` → بخش پیامک، و از طریق `PATCH /api/v1/admin/settings`). اگر تنظیم نشده باشد، مقدار پیش‌فرض `SmsWalletController::SMS_PRICE_RIALS = 500` ریال به‌عنوان fallback استفاده می‌شود. `GET /api/v1/sms/wallet/balance` این مقدار را در `sms_price_rials` و تعداد تخمینی پیامک را در `estimated_sms_count` برمی‌گرداند. --- diff --git a/src/Sms/Provider/KavehNegarProvider.php b/src/Sms/Provider/KavehNegarProvider.php index 1b9ded2d..44f1eee3 100644 --- a/src/Sms/Provider/KavehNegarProvider.php +++ b/src/Sms/Provider/KavehNegarProvider.php @@ -3,6 +3,7 @@ namespace App\Sms\Provider; use Psr\Log\LoggerInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class KavehNegarProvider implements SmsProviderInterface @@ -22,20 +23,39 @@ class KavehNegarProvider implements SmsProviderInterface public function getName(): string { return 'kavenegar'; } + // Kavenegar فقط GET با query را می‌پذیرد (مطابق مستند رسمی). هیچ درخواست POST/body نباید ساخته شود. + // GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمی‌فرستد (رفع idle timeout) و حجم درخواست کوچک می‌ماند. + private const MAX_ATTEMPTS = 3; + + /** یک GET به کاوه‌نگار با query params و retry برای خطاهای گذرا؛ آرایهٔ پاسخ را برمی‌گرداند. */ + private function get(string $path, array $query): array + { + $url = self::BASE . '/' . $this->key() . $path; + for ($attempt = 1; ; $attempt++) { + try { + return $this->httpClient->request('GET', $url, [ + 'query' => $query, + 'timeout' => 15, + 'max_duration' => 30, + 'proxy' => null, + ])->toArray(); + } catch (TransportExceptionInterface $e) { + if ($attempt >= self::MAX_ATTEMPTS) { + throw $e; + } + usleep(500_000 * $attempt); + } + } + } + public function send(string $mobile, string $message): bool { try { - $resp = $this->httpClient->request('POST', - self::BASE . '/' . $this->key() . '/sms/send.json', [ - 'body' => http_build_query([ - 'receptor' => $mobile, - 'message' => $message, - 'sender' => $this->sender(), - ]), - 'timeout' => 10, - ] - ); - $data = $resp->toArray(); + $data = $this->get('/sms/send.json', [ + 'receptor' => $mobile, + 'message' => $message, + 'sender' => $this->sender(), + ]); return ($data['return']['status'] ?? 0) === 200; } catch (\Throwable $e) { $this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]); @@ -73,13 +93,7 @@ class KavehNegarProvider implements SmsProviderInterface $params[$slot] = $this->forSlot($slot, (string) $v); } } - $resp = $this->httpClient->request('POST', - self::BASE . '/' . $this->key() . '/verify/lookup.json', [ - 'body' => http_build_query($params), - 'timeout' => 10, - ] - ); - $data = $resp->toArray(); + $data = $this->get('/verify/lookup.json', $params); return ($data['return']['status'] ?? 0) === 200; } catch (\Throwable $e) { $this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]); diff --git a/tests/Sms/KavehNegarProviderTest.php b/tests/Sms/KavehNegarProviderTest.php new file mode 100644 index 00000000..46b5be54 --- /dev/null +++ b/tests/Sms/KavehNegarProviderTest.php @@ -0,0 +1,49 @@ + */ + private array $captured = []; + + private function provider(): KavehNegarProvider + { + $client = new MockHttpClient(function (string $method, string $url, array $options): MockResponse { + $this->captured = ['method' => $method, 'url' => $url]; + return new MockResponse(json_encode(['return' => ['status' => 200]])); + }); + + return new KavehNegarProvider($client, new NullLogger(), 'TESTKEY', '10004346'); + } + + public function testSendTemplateUsesGetWithQuery(): void + { + $ok = $this->provider()->sendTemplate('09120671756', 'clinicpro-otp', ['token' => '1234']); + + $this->assertTrue($ok); + $this->assertSame('GET', $this->captured['method']); + $this->assertStringContainsString('/TESTKEY/verify/lookup.json', $this->captured['url']); + $this->assertStringContainsString('receptor=09120671756', $this->captured['url']); + $this->assertStringContainsString('template=clinicpro-otp', $this->captured['url']); + $this->assertStringContainsString('token=1234', $this->captured['url']); + } + + public function testSendUsesGetNotPost(): void + { + $this->provider()->send('09120671756', 'hello'); + + $this->assertSame('GET', $this->captured['method']); + $this->assertStringContainsString('/TESTKEY/sms/send.json', $this->captured['url']); + } +}