# HTTP



# Common.HTTP.ProxyFetch — загрузка URL-контента через прокси

**Пакет:** `Http`  
**Полное имя компонента:** `Common.Http.ProxyFetch`

## Что это

**ProxyFetch** — системный PHP-компонент (V8Wrapper) для загрузки содержимого по HTTP/HTTPS URL внутри сценариев Metabot.

Проще всего воспринимать так:

**это аналог `file_get_contents($url)`, но с поддержкой прокси, таймаутов и повторных попыток**, на том же cURL-стеке, что используется для надёжных загрузок в платформе (`CdnFtp`).

## Зачем нужен ProxyFetch

- `file_get_contents()` и часть HTTP-клиентов **не используют** прокси из окружения инстанса → запросы «уходят мимо» SOCKS5/HTTP-прокси и падают или «висят».
- Нет единых **таймаутов** и **ретраев** → сложнее переживать кратковременные сбои CDN и 5xx.
- Дублировать логику прокси в каждом плагине нецелесообразно.

ProxyFetch даёт **одну точку входа** для сценариев и плагинов: загрузить строку по URL или получить метаданные (HEAD) с теми же правилами отказоустойчивости.

## Два контракта: generic fetch vs файловая загрузка

В платформе **два разных метода** с **разной семантикой успеха**. Не путайте их:

<table id="bkmrk-fetchurlcontents%28%29-%2F"><thead><tr><th>  
</th><th>`fetchUrlContents()` / ProxyFetch</th><th>`downloadFileFromUrlToBusiness()`</th></tr></thead><tbody><tr><td>**Назначение**</td><td>Получить тело ответа в память</td><td>Скачать и сохранить файл на диск</td></tr><tr><td>**`ok` / успех**</td><td>`curl_exec !== false` и `http_code > 0`</td><td>Только `2xx + non-empty body`</td></tr><tr><td>**404**</td><td>`ok=true`, `http_code=404` — **by design**</td><td>Ошибка, return `null`</td></tr><tr><td>**Пустой body**</td><td>Допустим (204, HEAD-like API)</td><td>Ошибка</td></tr><tr><td>**Retry**</td><td>cURL error / `http_code=0` / `5xx`</td><td>cURL error / `http_code=0` / не-2xx / пустой body</td></tr></tbody></table>

> **Важно:** если кто-то «оптимизирует» один контракт под другой — сломает либо плагины, либо загрузку файлов.

## Где используется

- Загрузка CSV/TXT/JSON по URL перед разбором в сценарии.
- Предварительная проверка размера/доступности файла по URL.
- Замена прямых `file_get_contents` в плагинах вроде `Common.Files.Converter`, `Common.Integrations.Request` (миграция по желанию команды).
- Любые кейсы, где бот на сервере обязан ходить в интернет **только через прокси**.

## Где находится компонент

Подключение в JavaScript-сценарии (V8):

```javascript
const ProxyFetch = require("Common.Http.ProxyFetch")

```

PHP-класс плагина:

`Plugins\Dynamic\Common\Http\V8Wrapper\ProxyFetch`

Платформенное ядро (можно вызывать из PHP-плагинов без V8):

`App\Services\CdnFtp::fetchUrlContents()`  
`App\Services\CdnFtp::getFileInfoByUrl()` (расширенная сигнатура с прокси и ретраями)

## Как работает ProxyFetch

Архитектура из трёх слоёв:

```text
V8 JS  →  Common.Http.ProxyFetch  →  mergeWithDefaults (http_outbound + options)
       →  CdnFtp::fetchUrlContents / getFileInfoByUrl
       →  CdnFtp::curlExecWithFallback / curlExec  (общий cURL-раннер)
       →  cURL (прокси, таймауты, SSL, exec, диагностика)

```

**Общий раннер:** `curlExec()` — единая точка init/proxy/timeout/SSL/exec/close; `curlExecWithFallback()` — обёртка с `href_encode(url)` → fallback на raw URL. Переиспользуется в `fetchUrlContents` и `getFileInfoByUrl`.

**Ретраи:** отдельные «раунды»; в каждом раунде `curlExecWithFallback` перебирает варианты URL. Повтор, если `curl_exec` дал сбой, `http_code === 0` или `http_code >= 500`.

**Логирование:** при `HTTP_OUTBOUND_LOG_REQUESTS=true`:

- `CdnFtp outbound fetch: retry ...` / `failed after ...` — для fetchUrlContents
- `CdnFtp outbound fileInfo: retry ...` / `failed after ...` — для getFileInfoByUrl

## Что нужно настроить

### 1. Конфиг `config/http_outbound.php`

Загружается автоматически; значения берутся из `.env`.

### 2. Переменные `.env`

<table id="bkmrk-%D0%9F%D0%B5%D1%80%D0%B5%D0%BC%D0%B5%D0%BD%D0%BD%D0%B0%D1%8F-%D0%9D%D0%B0%D0%B7%D0%BD%D0%B0%D1%87%D0%B5%D0%BD%D0%B8"><thead><tr><th>Переменная</th><th>Назначение</th></tr></thead><tbody><tr><td>`HTTP_OUTBOUND_PROXY`</td><td>Строка прокси (например `socks5://login:password@host:port`). Пусто — прокси не задаётся.</td></tr><tr><td>`HTTP_OUTBOUND_PROXY_VERSION`</td><td>Опционально: `v4` или `v6` (разрешение DNS/IP для cURL).</td></tr><tr><td>`HTTP_OUTBOUND_TIMEOUT`</td><td>Таймаут запроса, сек (по умолчанию 30).</td></tr><tr><td>`HTTP_OUTBOUND_CONNECT_TIMEOUT`</td><td>Таймаут на установку соединения, сек (по умолчанию 10).</td></tr><tr><td>`HTTP_OUTBOUND_RETRIES`</td><td>Число раундов попыток (по умолчанию 3).</td></tr><tr><td>`HTTP_OUTBOUND_RETRY_AFTER_SEC`</td><td>Пауза между раундами, сек (по умолчанию 2).</td></tr><tr><td>`HTTP_OUTBOUND_LOG_REQUESTS`</td><td>`true`/`false` — писать диагностические строки в лог (по умолчанию true).</td></tr></tbody></table>

**Важно:** эта группа **независима** от `botman.telegram`. Если на проекте один прокси на всё, можно задать одинаковые значения в обеих группах; если прокси разные — настраивайте раздельно.

### 3. Установка плагина на инстансе

Файл уже в `Plugins/Dynamic/Common/Http/V8Wrapper/ProxyFetch.php`. Автозагрузка через SPL autoload в `ModuleLoader` — дополнительных действий не требуется.

## Создание плагина в админке

Если плагин **не** поставляется из репозитория, создайте его вручную:

1. **Плагин:** имя каталога/пакета `Http`, тип стандартный, **Common**, уровень доступа **SYSTEM**.
2. **Скрипт:** имя `ProxyFetch`, тип **PHP (WRAPPER FOR V8)**.
3. Вставьте код из файла `Plugins/Dynamic/Common/Http/V8Wrapper/ProxyFetch.php` (namespace и имя класса должны совпадать).
4. Включите скрипт (**is\_enabled**).

Платформа сохранит PHP-файл в структуру динамических плагинов; `ModuleLoader` через SPL autoload видит класс автоматически.

## Сигнатура вызова (JavaScript / V8)

### `getContents`

```javascript
const ProxyFetch = require("Common.Http.ProxyFetch")

let result = ProxyFetch.getContents("https://example.com/data.csv")

if (!result.ok) {
  debug("Ошибка загрузки: " + result.last_curl_error)
  return false
}

let text = result.content

```

С переопределением параметров:

```javascript
let result = ProxyFetch.getContents("https://example.com/big.bin", {
  proxy: "socks5://user:pass@proxy.example:1080",
  timeout: 120,
  connect_timeout: 15,
  max_attempts: 5,
  retry_after_sec: 3
})

```

### `getFileInfo`

```javascript
let info = ProxyFetch.getFileInfo("https://example.com/photo.jpg")

if (info.exists && info.size_kb < 20480) {
  // условно безопасный размер
}

```

## Параметры `options`

<table id="bkmrk-%D0%9F%D0%BE%D0%BB%D0%B5-%D0%A2%D0%B8%D0%BF-%D0%9E%D0%B1%D1%8F%D0%B7%D0%B0%D1%82%D0%B5%D0%BB%D0%B5%D0%BD-"><thead><tr><th>Поле</th><th>Тип</th><th>Обязателен</th><th>Описание</th></tr></thead><tbody><tr><td>`proxy`</td><td>string</td><td>Нет</td><td>Переопределить прокси для этого вызова</td></tr><tr><td>`timeout`</td><td>number</td><td>Нет</td><td>Таймаут cURL, сек (дефолт из `http_outbound.timeout`)</td></tr><tr><td>`connect_timeout`</td><td>number</td><td>Нет</td><td>Таймаут соединения, сек</td></tr><tr><td>`max_attempts`</td><td>number</td><td>Нет</td><td>Число раундов ретраев (`http_outbound.retries`)</td></tr><tr><td>`retry_after_sec`</td><td>number</td><td>Нет</td><td>Пауза между раундами</td></tr><tr><td>`log_requests`</td><td>boolean</td><td>Нет</td><td>Логировать попытки (`http_outbound.log_requests`)</td></tr></tbody></table>

## Формат ответа `getContents`

<table id="bkmrk-%D0%9F%D0%BE%D0%BB%D0%B5-%D0%A2%D0%B8%D0%BF-%D0%9E%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5-ok"><thead><tr><th>Поле</th><th>Тип</th><th>Описание</th></tr></thead><tbody><tr><td>`ok`</td><td>boolean</td><td>Транспорт доставил ответ: `curl_exec !== false` и `http_code > 0`</td></tr><tr><td>`content`</td><td>string | null</td><td>Тело ответа; `null` при транспортном провале</td></tr><tr><td>`http_code`</td><td>number</td><td>HTTP-код последнего ответа</td></tr><tr><td>`attempts`</td><td>number</td><td>Сколько раундов было выполнено</td></tr><tr><td>`last_curl_error`</td><td>string</td><td>Текст ошибки при `ok === false`; при успехе — пустая строка</td></tr></tbody></table>

**Про 404:** при ответе 404 платформа вернёт `ok === true` и тело страницы ошибки — **всегда проверяйте `http_code`**, если вам нужен именно успешный ресурс. Это by design: generic fetch не навязывает семантику HTTP-статусов.

## Формат ответа `getFileInfo`

Прежние поля:

- `exists` (0/1) — по сути «код ответа 200».
- `size_kb`, `type`, `mime_type`, `name`.

Добавлены:

- `http_code`
- `attempts`
- `last_curl_error`

## Использование из PHP (без V8)

```php
use App\Services\CdnFtp;

$result = CdnFtp::fetchUrlContents(
    $url,
    config('http_outbound.proxy'),
    (int) config('http_outbound.timeout', 30),
    (int) config('http_outbound.connect_timeout', 10),
    (int) config('http_outbound.retries', 3),
    (int) config('http_outbound.retry_after_sec', 2),
    (bool) config('http_outbound.log_requests', true)
);

if (!$result['ok']) {
    // логировать $result['last_curl_error']
}

```

HEAD / метаданные:

```php
$info = CdnFtp::getFileInfoByUrl(
    $url,
    config('http_outbound.proxy'),
    (int) config('http_outbound.connect_timeout', 10),
    (int) config('http_outbound.timeout', 30),
    (int) config('http_outbound.retries', 3),
    (int) config('http_outbound.retry_after_sec', 2)
);

```

Одноаргументный вызов `CdnFtp::getFileInfoByUrl($url)` сохраняет старое поведение (без таймаутов из `http_outbound`).

## Пример замены `file_get_contents` в плагине

**Было:**

```php
$data = file_get_contents($url);

```

**Стало (через ядро):**

```php
$fetch = \App\Services\CdnFtp::fetchUrlContents(
    $url,
    config('http_outbound.proxy'),
    (int) config('http_outbound.timeout', 30),
    (int) config('http_outbound.connect_timeout', 10),
    (int) config('http_outbound.retries', 3),
    (int) config('http_outbound.retry_after_sec', 2)
);
if (!$fetch['ok']) {
    throw new \RuntimeException($fetch['last_curl_error'] ?: 'fetch failed');
}
$data = $fetch['content'];

```

## Обработка ошибок

<table id="bkmrk-%D0%A1%D0%B8%D1%82%D1%83%D0%B0%D1%86%D0%B8%D1%8F-%D0%A7%D1%82%D0%BE-%D0%BE%D0%B6%D0%B8%D0%B4%D0%B0%D1%82%D1%8C"><thead><tr><th>Ситуация</th><th>Что ожидать</th></tr></thead><tbody><tr><td>Таймаут</td><td>`ok === false`, в `last_curl_error` часто `cURL error 28: ...`</td></tr><tr><td>Прокси недоступен</td><td>`ok === false`, типично `cURL error 7: ...`</td></tr><tr><td>5xx после ретраев</td><td>`ok === false`, `last_curl_error` содержит `HTTP 503` и т.д.</td></tr><tr><td>404</td><td>`ok === true`, `http_code === 404` — проверяйте код</td></tr><tr><td>Невалидный URL / пустая строка</td><td>`ok === false`, `last_curl_error` поясняет причину</td></tr></tbody></table>

Сетевую ошибку и «битый» контент (HTML вместо CSV) различайте по **`http_code`** и валидации содержимого в сценарии.

## Как отлаживать

1. **`.env`** — заданы ли `HTTP_OUTBOUND_PROXY`, таймауты и ретраи.
2. **Ручной `curl`** с того же сервера (с тем же прокси, если нужен) — доступен ли URL.
3. **Логи приложения** — строки `CdnFtp outbound fetch: ...` / `CdnFtp outbound fileInfo: ...` при включённом `HTTP_OUTBOUND_LOG_REQUESTS`.
4. **`result.last_curl_error`** / **`info.last_curl_error`** — конкретная причина отказа.
5. **`http_code`** — отличить 404/403 от обрыва соединения.
6. **`attempts`** — сколько раундов реально выполнено (ретраи работают).

## FAQ

**Можно ли задать другой прокси только для одного вызова?**  
Да, передайте `proxy` в объекте `options`.

**Как отключить ретраи?**  
`max_attempts: 1` в `options` или `HTTP_OUTBOUND_RETRIES=1`.

**Чем отличается от `bot.downloadFileFromUrl()`?**  
Скачивание в сценарий бота кладёт файл в хранилище бизнеса. ProxyFetch / `fetchUrlContents` возвращают **строку в памяти**, без сохранения на диск платформы. Кроме того, семантика успеха разная: download требует 2xx, fetch допускает 404.

**Работает ли без прокси?**  
Да: если `HTTP_OUTBOUND_PROXY` пуст и в `options` не передан `proxy`, используется прямой cURL.

**Нужен ли отдельный токен или callback?**  
Нет, это синхронный HTTP-запрос из PHP, без webhook'ов.

## Что дальше

- Перевести `Common.Files.Converter` и `Common.Integrations.Request` на ProxyFetch / `CdnFtp::fetchUrlContents`.
- Перевести `downloadFileFromUrlToBusiness` на общий cURL-раннер (отдельная задача, follow-up).
- При необходимости расширить ядро (заголовки авторизации, POST) отдельной задачей.
- Держать в синхроне значения прокси с Telegram, если на проекте это один и тот же выход в интернет.

## Связанные материалы

- Спека: `specs/Task-9703-proxy-fetch-plugin.plan.md`
- Код: `app/Services/CdnFtp.php`, `Plugins/Dynamic/Common/Http/V8Wrapper/ProxyFetch.php`, `config/http_outbound.php`