# gnexus-synapse — Python-клиент Synapse

Тонкий клиент Ingestion API v1 хаба уведомлений Gnexus Synapse
(`git+https://git.gnexus.space/git/root/gn-synapse.git`): собрать конверт,
проверить локально, послать — без ретраев и локальных очередей (очередь
у Synapse своя, приём отвечает мгновенно). Контракт — `docs/05-ingestion-api.md`
в репозитории `gn-synapse`.

## Установка

```bash
pip install "git+https://git.gnexus.space/git/root/gn-synapse-client-py.git@v0.1.0"
```

Серверный репозиторий — `https://git.gnexus.space/git/root/gn-synapse.git`.
Зависимость одна: `httpx`.

## Quickstart

```python
from gnexus_synapse import SynapseClient

# аргументы сильнее env; фолбэк — SYNAPSE_URL / SYNAPSE_API_KEY
syn = SynapseClient("http://synapse.gnexus.space:8013", "syn_...")

event = syn.send("bugtrail", "test", "failed",
                 payload={"user_id": "<sub gnexus-auth>", "error": "..."},
                 priority="high", dedup_key="run-2026-10-03")
print(event.id, event.status)  # queued

# пожарная отправка: ошибки Synapse не ломают сервис, пишутся в warning
syn.emit("gntodo", "task", "created", payload={"user_id": uid})

status = syn.status(event.id)          # статусы доставок по каналам
result = syn.send_batch([...])         # batch: result.accepted / result.rejected
```

env-фолбэк (для сервисов без своей конфигурации):

```
SYNAPSE_URL=http://synapse:8013
SYNAPSE_API_KEY=syn_...          # выдаёт админ (create-key / MCP key_issue), печатается один раз
SYNAPSE_DEFAULT_SOURCE=bugtrail  # необязательно: не указывать source в каждом вызове
SYNAPSE_TIMEOUT=10               # секунды
```

`default_source` — лучшая защита от частой ошибки 403: ключ принадлежит
одному источнику, и `source` в конверте обязан совпадать с ним.

## API

| Метод | Что делает | Ошибки |
|---|---|---|
| `send(source, subject, action, *, priority, payload, dedup_key, ttl_seconds, scheduled_at)` | POST /api/v1/events → `Event(id, status, deduplicated)` | типизированные, см. ниже |
| `emit(...)` | тот же, ловит SynapseError → warning в логгер, возвращает `Event \| None` | не бросает |
| `send_batch(events)` | массив конвертов; локально битые не посылаются; rejected — **не** исключение (частичный успех норма); транспортный сбой/5xx — исключение | `SynapseError` |
| `status(event_id)` | GET /api/v1/events/{id} → `EventStatus` (в т.ч. `deliveries`) | `SynapseNotFoundError` |
| `health()` / `ready()` | диагностика без ключа | — |
| `close()` | закрыть свой httpx.Client (инжектный не трогает) | — |

Исключения (все — наследники `SynapseError` с полями `detail`, `status_code`):

| Класс | Значение |
|---|---|
| `SynapseConfigError` | нет url/api_key/source в аргументах и env |
| `SynapseConnectionError` | Synapse недоступен (DNS/timeout/refused), `__cause__` — оригинал |
| `SynapseAuthError` | 401 — ключ отсутствует/неизвестен/отозван (или источник в архиве) |
| `SynapseForbiddenError` | 403 — `source` конверта ≠ источник ключа; задайте `default_source` |
| `SynapseValidationError` | 422 сервера или **локальная** ошибка конверта (`status_code is None`) |
| `SynapseNotFoundError` | 404 — событие не у источника ключа |
| `SynapseServerError` | 5xx — событие не принято, повторите при желании |

Локальная валидация зеркалит серверную (`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$`,
1..64; priority low/normal/high/critical; ttl 1..604800; dedup_key ≤ 255;
payload — JSON-объект), поэтому SynapseValidationError от клиента — почти
всегда ошибка кода, ловить её нужно в тестах, а не в рантайме.
Сервер игнорирует незарегистрированные пары (subject, action) — их регистрирует
админ (`add-type` / MCP `type_register`).

Асинхронный близнец для FastAPI-сервисов:

```python
from gnexus_synapse import AsyncSynapseClient
syn = AsyncSynapseClient()          # httpx.AsyncClient, await syn.aclose()
event = await syn.send("gntodo", "task", "created", payload={"user_id": uid})
```

## Конвенции и оговорки

- **Дедуп best-effort** (окно 24 ч, сервер не даёт гарантии уникальности):
  не стройте бизнес-логику на отсутствии дублей.
- **Дедуп вернул 202 с `deduplicated=true`** — это id *первого* события, не ошибки.
- `payload.user_id` — конвенция «о ком событие» (нестроковое значение → адресные
  доставки skipped): клиент пишет warning, но не блокирует.
- `scheduled_at` — резерв контракта v1: передаётся, сервер пока не обрабатывает.
- Свой httpx.Client создаётся с явным `timeout` (у httpx дефолт — «бесконечно»).
  Инжектный клиент (`http_client=...`) не трогается — таймаут настраивайте сами.
- user_agent `gnexus-synapse-py/<версия>` — по нему Synapse видит, кто источался.

## Разработка

```bash
uv venv -p 3.12 && uv pip install -e . -e '.[dev]'
pytest            # юнит-тесты (MockTransport)
ruff check src tests && mypy src
# интеграционный смок против живого стека:
SYNAPSE_URL=http://localhost:8013 SYNAPSE_API_KEY=syn_... python examples/plain/smoke.py
```