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_passwordUsername + password, session-basedETM
api_keyОдин статический ключ в URL/headerSysteme Electric (?accessCode=...)
bearer_tokenСтатический Bearer token
oauth2Standard OAuth2 (authorization_code / client_credentials)будущие SaaS-поставщики
master_key_token_exchangeLong-lived MasterKey → exchange на короткий access_token через auth endpointDKC (/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_rejected401/403, session не восстанавливаетсяMarkFailing после retry-обрядов
ingestion.connector.session_expiredсервер вернул «сессия невалидна» (у ETM/IEK — 403)re-login, повторить запрос
ingestion.connector.rate_limited429 / server-side throttlingEnrichmentJobSkipped + reschedule
ingestion.connector.not_found404записать пустое observation (товар исчез), не fail
ingestion.connector.on_requestсервер вернул «цена по запросу»OfferMarkedOnRequest, не fail
ingestion.connector.transient5xx / networkexponential backoff
ingestion.connector.permanentparse error, schema violationEnrichmentJobFailed + DLQ
ingestion.connector.partial_successчасть payload’ов ок, часть — failEnrichmentJobCompleted с 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_ref axis только если 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);
  • SessionExpired recovery path;
  • AsyncReportJob happy path + timeout path;
  • OnRequest convention (price = 0 при ETM);
  • error mapping → errors.Code taxonomy (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 расширяется, ломаются старые consumers enrichment.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.