ADR-0024: Контракт supplier connector
Status: accepted Date: 2026-04-18 Deciders: команда проекта
Контекст
Первый поставщик — ETM (REST API). Анализ их API (PDF спецификация от 17.10.2024) показал набор требований, который будет повторяться у других дистрибьюторов:
- Session-based auth с собственным rate limit на login (1/2мин) отдельно от rate limit на data endpoints (1/сек).
- Несколько типов идентификатора товара в одном API:
cli(клиентский код),etm(внутренний код поставщика),mnf(артикул производителя). - Asynchronous report endpoints:
POST /job/create → uuid → GET /job/{uuid}с состояниями (0=создана,1=готова,2=ошибка,3=ждёт запуска). - Watermarked vs non-watermarked media — right требует отдельного согласования.
- 403 =
session invalid, 401 не используется; семантика отличается от общепринятой «auth failed». - Feature scope может ограничиваться до части складов (по договорённости с менеджером).
Текущий Connector интерфейс в ../integration-patterns.md покрывает только happy path REST+pagination. Нужен формальный контракт, который защитит core от специфики отдельного поставщика.
Решение
Обязательный контракт connector — все connectors (REST / file / webhook) реализуют минимальный набор пунктов ниже. Нарушение любого из них на этапе регистрации connector — composition root не стартует.
1. SessionManager port
Connector не знает, как получает session-ключ. Использует SessionManager.Acquire(ctx, CredentialContext) (SessionHandle, error) и .Release. Реализация хранит (session_id, valid_until) в Redis, проактивно refreshит за refresh_before = min(5min, TTL/10).
Для поставщиков без session (api-key / bearer) — SessionManager возвращает опосредующий handle, тот же port.
AuthSchema (на стороне SupplierCredential — см. Credentials BC) поддерживает:
| Schema | Описание | Пример |
|---|---|---|
login_password | Username + password, session-based | ETM |
api_key | Один статический ключ в URL/header | Systeme Electric (?accessCode=...) |
bearer_token | Статический Bearer token | — |
oauth2 | Standard OAuth2 (authorization_code / client_credentials) | будущие SaaS-поставщики |
master_key_token_exchange | Long-lived MasterKey → exchange на короткий access_token через auth endpoint | DKC (/auth.access.token/{MasterKey}) |
public_no_auth | Публичный файловый feed без авторизации | КЭАЗ (files.keaz.ru/ftp/keaz.xls) |
custom | Нестандартная схема; SessionManager реализация содержит логику per-connector | — |
Для master_key_token_exchange SessionManager хранит два артефакта: долгоживущий MasterKey (зашифрован master_key_provider как и остальные secrets) и короткоживущий access_token с valid_until. Refresh — автоматический.
2. Двухуровневый rate budget
Connector запрашивает токены в двух независимых bucket:
AuthBucket— только для login / token-refresh (у ETM: 1 req / 120s). Исчерпание → ожидание, не fail.DataBucket[endpoint_class]— per-endpoint (price, stock, goods, async_poll, …). Исчерпание →EnrichmentJobSkipped+ reschedule.
Два bucket никогда не смешиваются. См. ADR-0008.
3. SKU identity pack
Каждый RawPayload несёт полный набор идентификаторов, которые вернул поставщик:
SkuIdentityPack {
supplier_sku // внутренний код поставщика (обязательно)
manufacturer_article // артикул производителя (опционально)
manufacturer_code // код производителя в справочнике поставщика (опционально)
client_sku // код клиента из его cross-reference (опционально)
}
Offers BC раскладывает это в SupplierOffer.supplier_sku + SupplierOffer.manufacturer_article + OfferSourceCredentialAdded с client_sku (если был).
4. Error taxonomy
Connector обязан нормализовать HTTP/transport ошибки в фиксированные коды
из internal/platform/errors (ADR-0052 §2). Никаких локальных
error-структур / sentinel-ов в ingestion/domain — они больше не
существуют (миграция 2026-05-09):
errors.Code | Причина | Рекомендация worker’а |
|---|---|---|
ingestion.connector.auth_rejected | 401/403, session не восстанавливается | MarkFailing после retry-обрядов |
ingestion.connector.session_expired | сервер вернул «сессия невалидна» (у ETM/IEK — 403) | re-login, повторить запрос |
ingestion.connector.rate_limited | 429 / server-side throttling | EnrichmentJobSkipped + reschedule |
ingestion.connector.not_found | 404 | записать пустое observation (товар исчез), не fail |
ingestion.connector.on_request | сервер вернул «цена по запросу» | OfferMarkedOnRequest, не fail |
ingestion.connector.transient | 5xx / network | exponential backoff |
ingestion.connector.permanent | parse error, schema violation | EnrichmentJobFailed + DLQ |
ingestion.connector.partial_success | часть payload’ов ок, часть — fail | EnrichmentJobCompleted с details |
Connector конструирует ошибку через errors.New(ctx, code, opts...) /
errors.Wrap(ctx, cause, code, opts...); per-kind ratemeta и Retry-After
живут в errors.WithSafeDetails(map) / errors.WithSafeDetail(key,value).
HTTP-статус → код мапится канонически через
errors.ConnectorCodeFromHTTP(int); per-supplier override (например,
Systeme, где 403 — реальный отказ авторизации) кладёт нужный код прямо в
errors.Wrap.
У ETM/IEK: 403 → session_expired. Три подряд auth_rejected после
свежего login → MarkFailing.
5. AsyncReportJob subflow
Для поставщиков с POST create → poll → fetch workflow:
JobStatus (расширен):
queued → running → awaiting_report → fetching → completed
↘ failed
EnrichmentJob получает два новых поля:
remote_job_ref:{uuid, provider, submitted_at, deadline}— для polling.poll_strategy:{initial_delay, interval, max_wait, backoff}.
Worker NE таймаутит сам job пока awaiting_report в пределах max_wait. Poll считается вызовом к DataBucket[async_poll], не DataBucket[data].
6. Media rights flag
CredentialFeatures расширен media_unwatermarked. Connector при parse media помечает:
MediaAsset {
...
watermarked: bool
rights_source_credential_ref // какая credential дала «чистую» версию
}
Если credential не имеет media_unwatermarked — parse сохраняет watermarked=true и игнорирует «чистые» URLs, если поставщик их вернул по ошибке.
7. Capabilities declaration
Connector.Capabilities() — расширено:
Capabilities {
TransportKind TransportKind // rest / file_feed / webhook / html_scrape (ADR-0027)
RequiresHeadlessBrowser bool // если true — deploy с Chromium pod
RespectsRobotsTxt bool // true обязательно для html_scrape
Catalog bool
Characteristics bool
Prices bool // поддерживает ли price endpoint
PriceSet PriceSetShape // какие цены возвращает: net/gross/tarif/retail
Stock bool
StockForecast bool // возвращает ли forecast / incoming
WarehouseKinds []WarehouseKind // какие типы складов отдаёт
AsyncJobs bool
Webhooks bool
Media MediaCapability // images/videos/certificates + watermark policy
CodeTypes []SkuCodeType // cli / supplier / manufacturer
DictionaryExports []DictKind // manufacturer / classification / characteristics
Marketplace Marketplace // marketplace-специфика (ADR-0026)
Taxonomies Taxonomies // поддержка federated identity standards (ADR-0028)
IncrementalSync IncrementalSync // revision/date-based delta (ADR-0029)
FileFeed FileFeed? // для TransportKind=file_feed (см. ниже)
MultiCurrency MultiCurrency // поддержка мультивалютных observations
}
Taxonomies {
SupportedStandards []TaxonomyStandardRef // etim_v7, eclass_10, unspsc, gs1_gtin, mpn, okpd2, ...
NativeCoding bool // payload содержит external_code напрямую
ExportsDictionary bool // есть endpoint для выгрузки dictionary (DKC /etim/*)
}
IncrementalSync {
Supported bool
DataScopes []DataScope // materials | stock | price | characteristics | ...
CursorKind CursorKind // revision | date | opaque_token
MinFetchInterval duration
MaxFetchInterval duration
CursorLifetime duration?
SupportsBulkDeltas bool
}
FileFeed {
Url string
Format FileFormat // xls | xlsx | csv | json | xml | ndjson | bmecat
Compression Compression // none | zip | gzip
CheckMode CheckMode // etag | last_modified | timestamp_in_url | always
Encoding Encoding // utf-8 | windows-1251 | iso-8859-1
UpdateSchedule CronExpr?
}
MultiCurrency {
SupportedCurrencies []ISO4217 // ["RUB", "KZT"]
DefaultCurrency ISO4217
ParallelCurrencyFetch bool // true — один запрос возвращает все валюты; false — по endpoint per currency
}
TransportKind enum:
rest // классический HTTP/JSON API (ETM)
file_feed // FTP / S3 / HTTP file download
webhook // push со стороны поставщика
html_scrape // HTML scraping (ADR-0027)
Marketplace {
IsMarketplace bool // один offer → N sellers
SellerExports bool // отдаёт ли список seller'ов с rating
CrossSellerInventory bool // агрегирует stock по seller'ам или отдаёт отдельно
}
Core использует это для route decisions:
- Pricing пропускает connectors без
Prices=true. - Deploy-система отказывается поднимать
RequiresHeadlessBrowser=trueбез Chromium base image. - Observation ingestion обрабатывает
seller_refaxis только еслиMarketplace.IsMarketplace=true.
8. Egress constraint flag
Поставщики, ограничивающие источник запроса по IP (ETM требует официальное письмо для foreign hosting), декларируют:
EgressPolicy {
fixed_ip_required bool
allowed_cidrs []string?
user_agent_signature string? // для scraper'ов — идентифицируемый UA (ADR-0027)
}
Деплой-система отказывается подни́мать такой connector на pod без привязанного egress IP. Для html_scrape connectors user_agent_signature обязателен — scraper маскироваться под browser запрещено.
9. Provider contract tests
Каждый connector имеет provider contract test suite (ADR-0023), реализующий:
- auth success / auth fail (bad creds);
- minimal data fetch (по
etm_codeдля ETM); - rate limit recovery (>= configured rate → 429 / wait);
SessionExpiredrecovery path;AsyncReportJobhappy path + timeout path;OnRequestconvention (price = 0 при ETM);- error mapping →
errors.Codetaxonomy (ingestion.connector.*).
Запускаются против sandbox endpoint поставщика (если есть) + против fixture-based double. Merge новой версии connector блокируется при провале.
Последствия
Плюсы
- Core не знает специфики конкретного поставщика — вся она в connector под формальным контрактом.
- Новый поставщик (drill-down через AsyncReportJob, strange 403 semantic, multi-price) встраивается без правок core моделей.
- Contract tests сразу ловят регрессии при смене API у поставщика.
- Capabilities позволяет Pricing / Matching / Ingestion принимать маршрутизационные решения детерминированно.
Минусы
- Connector становится увесистее — обязательно заполнить 9 пунктов.
JobStatusрасширяется, ломаются старые consumersenrichment.job.events.v1— нужна версия схемы v2 (backward-compatible: новый статусawaiting_report/fetchingдобавляется, не переименовываются существующие).
Нейтральные последствия
- Свод требований упрощает writing guide «как добавить нового поставщика» (процесс-документ
../../50-processes/adding-new-supplier.mdпридётся переписать).
Рассмотренные альтернативы
A. Минимальный connector, обработка специфики в core
Приводит к росту условных веток в Ingestion / Offers / Pricing по supplier_id. Нарушает ACL.
B. Каждый connector — изолированный микросервис без общего контракта
Теряем единую модель observation и общие метрики. Composition root превращается в case supplier.
C. Только session + rate, остальное — свободная форма
Не ловит асинхронные reports, media rights, error taxonomy. После 2-3 поставщиков получится хаос.
Ссылки
- ADR-0008 (rate limiter, пересмотрен с двумя bucket’ами).
- ADR-0011 (no-proxy).
- ADR-0014 (graceful degradation).
- ADR-0020 (outbox SLA).
- ADR-0023 (provider contract tests).
- ADR-0025 (price & stock observation extensions) — partner-ADR для payload-формата.
- ADR-0026 (marketplace observations) — Marketplace capabilities блок.
- ADR-0027 (HTML scraper connector pattern) — TransportKind.html_scrape + compliance gate.
- ADR-0028 (federated identity taxonomies) — Taxonomies capabilities блок, ETIM/GS1/eCl@ss/UNSPSC/OKPD2 etc.
- ADR-0029 (revision-based incremental sync) — IncrementalSync capabilities блок.
../integration-patterns.md.- ETM API spec — внутренняя копия
API_ETM_Product 17.10.24.pdf(27 стр., октябрь 2024). - Smart-shop.pro — каталог https://smart-shop.pro/ (scraping, marketplace, ADR-0027).
- Systeme Electric API — https://api.systeme.ru/documentation-api (REST, API key, ETIM native, multi-currency не поддерживает).
- КЭАЗ file feed — https://files.keaz.ru/ftp/keaz.xls (Pattern 2, public).
- DKC API — https://api.dkc.ru/documentation/ (REST, master_key_token_exchange, revision incremental, multi-currency RUB+KZT, ETIM export).
Amendment 2026-04-26 (P4b)
Capabilities.Marketplace extended with two flags driving downstream
seller-aware behavior. Existing connectors keep zero-value (non-marketplace);
no behavioral change for them.
type Marketplace struct {
IsMarketplace bool // existing
SellerExports bool // NEW — connector exposes seller dictionary
CrossSellerInventory bool // NEW — connector breaks down stock per seller
}The flags are advisory; pipeline does not branch on them in P4b. Future admin UI / analytics consumers may surface marketplace badges and per-seller inventory views.