fix: update KavehNegarProvider to use GET requests with query parameters to resolve 431 and idle timeout errors
This commit is contained in:
@@ -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/<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:
|
||||
|
||||
```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/<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` نمیفرستد، و حجم درخواست کوچک میماند.
|
||||
|
||||
```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 تشخیص، `<APIKEY>` را دستی جایگزین کن و در تاریخچه شل نگهندار.
|
||||
- تغییر 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 # باید خروجی خالی باشد
|
||||
```
|
||||
@@ -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` برمیگرداند.
|
||||
|
||||
---
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Sms;
|
||||
|
||||
use App\Sms\Provider\KavehNegarProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* الزام: همهی درخواستهای کاوهنگار باید GET با query باشند (مطابق مستند رسمی)،
|
||||
* هرگز POST/body — GET بدنه ندارد پس Expect: 100-continue و idle timeout رخ نمیدهد.
|
||||
*/
|
||||
class KavehNegarProviderTest extends TestCase
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user