Проєктування спільного контракту помилок у Rust. Будуємо NEXUS, частина 1
Мене звати Анатолій Шляхто — начальник відділу дилінгу АТ «Банк Альянс» та розробник ПЗ ЗСУ, і ця стаття буде максимально корисною для Rust-розробників, системних архітекторів та DevOps-інженерів, які прагнуть побудувати передбачуваний і відмовостійкий контракт обробки помилок у розподілених системах чи на межі WASM-компонентів.
Проєктування метаданих як контракту помилок для розподілених систем на Rust
Коли розробники презентують новий проєкт на Rust, вони зазвичай починають з архітектури мережі, рішень для зберігання даних, асинхронної оркестрації чи дизайну протоколів.
Я ж пішов іншим шляхом.
Першим крейтом, який я додав у NEXUS (розподілений гібридний Rust/WASM фреймворк для надлегких і захищених мікросервісів), став інструмент для обробки помилок: nx-error.
Це рішення не було питанням естетики — воно було суто архітектурним.
NEXUS будується навколо мікросервісів, типізованих контрактів та середовищ виконання, де збої мають описуватися узгоджено на всіх рівнях.

Ця стаття — перша з циклу матеріалів про технічний фундамент NEXUS. У ній я поясню, чому створив крейт nx-error, які проблеми він вирішує та які компроміси виявилися найважливішими: типізовані метадані, розділення контексту, передбачуване прокидання помилок та ергономіка з огляду на WASM архітектуру.
Короткий погляд на API
Публічне API навмисно зроблено мінімалістичним. Мета полягала в тому, щоб спростити визначення доменних помилок, але водночас зробити їх корисними для залежних систем: HTTP-рівня, логів, метрик, дашбордів та DevOps-інженерів.
Таку помилку можна використовувати у звичайному Rust-коді без написання кастомних маперів на кожному місці виклику:
А коли помилці потрібен операційний контекст, її можна збагатити деталями саме там, де цей контекст існує:
Саме такого рівня ергономіки я прагнув: один раз визначити семантику домену, автоматично зберігати контекст джерела та додавати діагностичні деталі лише там, де вони дійсно мають сенс.
Чому існуючих крейтів було недостатньо для цього проєкту
Rust уже має чудові інструменти для обробки помилок:
thiserror— ідеальний вибір для типізованих помилок у бібліотеках.anyhow— чудово підходить для агрегації на рівні застосунку та швидких ітерацій.miette— незамінний дляCLI-утиліт, де потрібна багата діагностика.
nx-error не намагається замінити їх в усіх можливих сценаріях. Він з’явився, тому що NEXUS має вужчий і значно суворіший набір обмежень.
1. Безпечний публічний та вичерпний операторський вивід — це різні речі
Один і той самий збій не повинен серіалізуватися однаково для кожної аудиторії.
Зовнішньому клієнту зазвичай потрібні:
- стабільний код помилки
- лаконічне повідомлення
- HTTP-статус
Оператору або пайплайну логів потрібні:
- повний ланцюг причин які призвели до виникнення помилки
- контекстні деталі
- підказки щодо усунення проблеми
- достатньо структурованих даних для індексації та кореляції
Це розділення мало стати базовим принципом дизайну крейту, а не милицею, яку дописують згодом у HTTP-хендлері.
2. Вартість передачі помилок має значення
У Rust розмір enum визначається його найбільшим варіантом. Це не проблема, поки варіанти не починають обростати інлайн-даними: рядками, ідентифікаторами, вкладеними структурами та спеціальним контекстом.
У звичайному серверному коді це часто прийнятно. Але в більш обмежених середовищах виконання, особливо на межах WASM-компонентів, «перевантажені» помилки стають проблемою. Через них значення помилок стає «важче» переміщувати, а межа між доменною семантикою та випадковою діагностикою стирається.
Мені був потрібен дизайн, у якому багата діагностика не призводила б автоматично до роздування розміру enum помилки.
3. Семантичні метадані повинні переживати розповсюдження (пропагації)
У багаторівневій системі код на нижчих рівнях часто вже знає важливу семантику проблеми:
- це
404, а не500 - це помилка конфігурації, а не порушення бізнес-правил
- це збій інфраструктури, який можна спробувати повторити, а не помилка клієнту
Я не хотів, щоб кожен рівень сервісу вручну переписував цю семантику у власних маперах. Такий підхід породжує дублювання і, що гірше, призводить до розбіжностей. Мені був потрібен спосіб визначити сенс помилки один раз і дозволити їй передбачувано вспливати нагору.
Основна ідея: помилки як контракти з метаданими
Ключовим архітектурним рішенням у nx-error було ставлення до помилок не просто як до значень, що реалізують std::error::Error, а як до структурованих носіїв метаданих.
Ці метадані корисні одразу для кількох споживачів:
- системи типів Rust
- мапінгу HTTP-відповідей
- фронтенд-локалізації (i18n)
- логів та телеметрії
- флоу сапорту та DevOps
Концептуально модель спирається на такі поля:
- статус
- машинозчитуваний код
- повідомлення
- опційні деталі помилки
- опційні підказки щодо ії вирішення
- ланцюжок джерел що призвів до неї
Концептуально це може звучати знайомо, але найважливішим є те, як це змінює поведінку інженерів. Щойно ці поля стають частиною контракту помилки, розробники перестають сприймати їх як непрозорі рядки і починають ставитись до них як до типізованих операційних подій.
Цей зсув у мисленні і був однією з головних цілей.
Проблема 1: «Товсті enum» погано масштабуються як загальносистемний контракт
Поширений патерн у Rust — прив’язувати контекст безпосередньо до варіантів enum:
Це легко писати, і локально такий підхід цілком виправданий. Але є структурний недолік: розмір enum визначається його найбільшим варіантом. У міру накопичення інлайн-контексту тип помилки стає важчим скрізь, навіть там, де більша частина цих даних взагалі не потрібна.
Це стає серйозним недоліком, коли тип помилки є частиною контракту усього проєкту, а не приватною деталлю імплементації модуля.
Підхід nx-error полягає в тому, щоб зберегти лаконічність декларації, уникаючи дизайну, де кожен варіант перетворюється на контейнер для масивних інлайн-даних. На практиці це означає, що багатий діагностичний контекст розглядається як прикріплені метадані, замість того, щоб змушувати кожен варіант безпосередньо володіти постійно зростаючим набором полів. Під капотом діагностичні дані виносяться у купу (heap) лише при виникненні помилки, залишаючи сам enum компактним у пам’яті та максимально дешевим для переміщення по стеку.
Мета не полягала в тому, щоб зробити помилки «крихітними за будь-яку ціну». Задача була більш специфічною: зберегти строгу типізацію на рівні enum, не змушуючи кожен рівень платити пам’яттю за максимальний обсяг контексту.
Цей компроміс має набагато більше значення для платформенного крейта, ніж для конкретного застосунку, оскільки тип помилки стає частиною спільного словника всіх інших крейтів, які від нього залежать.
Проблема 2: Бойлерплейт вбиває консистентність швидше за продуктивність
Найпростіший спосіб втратити контроль над моделлю помилок — це не погані абстракції, а дрібні рішення, розмазані по десятках модулів.
Без спільних конвенцій команди починають робити все вручну:
- вигадувати коди помилок на ходу
- писати різні повідомлення для одного й того ж класу збоїв
- мапити схожі проблеми на різні HTTP-статуси
- забувати прокидати вихідний контекст помилки, тим самим стираючі його
- непослідовно додавати деталі
На код-рев’ю цей дрейф не виглядає катастрофою, але в продакшені це стає головним болем:
- дашборди шумлять
- виміри метрик фрагментуються
- логи стає важко шукати
- поведінка клієнта API стає непередбачуваною
Тому nx-error сильно покладається на принцип конвенція понад конфігурацією.
Якщо назва варіанта вже несе певний сенс, макрос може автоматично вивести корисні дефолти:
UserNotFound→ кодUSER_NOT_FOUNDUserNotFound→ повідомлення"User not found"- неспецифічні збої можуть за замовчуванням отримувати адекватний статус внутрішньої помилки
І це не просто синтаксичний цукор. Це механізм зменшення семантичного дрейфу.
Наприклад:
Хороший інфраструктурний крейт має робити правильні речі найпростішими.
Проблема 3: Конвертація між рівнями — це біль і втрата даних
У багаторівневих Rust-системах прокидання помилок залишається ергономічним лише тоді, коли оператор ? може покластися на існуючі перетворення (From).
Але справжня проблема — не в написанні типізації для конвертації. Справжня проблема — втрата семантики.
Якщо нижчий рівень уже знає, що щось «не знайдено», верхнім рівням не слід знову вираховувати це через парсинг рядків чи зводити все до 500 Internal Server Error.
Саме тут модель nx-error розкривається найкраще. Доменна помилка може визначати джерело напряму:
А для інтеграцій одна доменна помилка може представляти кілька технічних збоїв спільному моделюванню на вищому архітектурному рівні:
Це зберігає критично важливий інваріант:
Конвертація повинна зберігати намір, а не стирати його.
Якщо код нижнього рівня вже знає, що щось «не знайдено», «невалідно» або «тимчасово недоступно», верхні рівні не повинні перевідкривати це заново, порівнюючи рядки чи згортаючи все підряд у 500 Internal Server Error.
Це стає особливо важливо, коли від цих відмінностей залежать механізми повторів, API-відповіді, дашборди та пайплайни алертів.
Проблема 4: Системі потрібні два погляди на один збій
Це, мабуть, була найважливіша вимога в NEXUS.
Клієнтське представлення помилки має бути стабільним і безпечним. Представлення для оператора — багатим і пояснювальним.
Безпечне для клієнта представлення може виглядати так:
Цього цілком достатньо для:
- розгалуження логіки на фронтенді
- пошуку ключів локалізації (i18n)
- повідомлень для користувача
- передбачуваних API-контрактів
Але всередині системи та сама помилка також повинна нести інформацію:
- яка саме змінна була невалідною
- де це сталося
- що було першопричиною
- як це можна виправити
Ось де важливим стає збагачення контекстом:
Той рівень коду, який володіє контекстом, повинен мати змогу прикріпити його, не змінюючи при цьому публічний контракт помилки.
Це має значення з чотирьох причин:
- Безпека — внутрішні інфраструктурні деталі не повинні витікати назовні автоматично.
- Спостережуваність — інженерам потрібна вичерпна інформація для розслідування інцидентів.
- Стабільність API — клієнти повинні залежати від стабільних кодів, а не від випадкових внутрішніх деталей.
- Композиційність — нижні рівні класифікують проблему, а верхні її збагачують контекстом.
Проблема 5: Багатша діагностика не повинна сповільнювати успішний сценарій
Багато корисного контексту дорого обчислювати (форматування рядків, серіалізація частин навантаження). Якщо це робити завчасно, успішний шлях «happy path» платить за форматування, яке ніколи не використається.
Тому «ліниве» додавання контексту має велике значення:
Я б уникнув називати це «бескоштовно» в абсолютному сенсі, тому що нічого нетривіального в системному програмному забезпеченні не буває буквально безкоштовним. Але справедливо сказати, що цей дизайн робить витрати на збагачення умовними — вони виникають лише при збої, тобто саме там, де їм і місце.
Проблема 6: Бектрейси — корисні, але не є панацеєю
Дискусії про помилки в Rust часто зводяться до бектрейсів. Вони корисні. Але в суворо обмежених середовищах вони не завжди є найпрактичнішим інструментом.
У NEXUS мене менше цікавило «перехоплення кожного фрейму», а більше:
- стабільна машинна класифікація
- чистий ланцюжок помилок
- читабельний для оператора вивід у логах
Деревовидна репрезентація часто набагато корисніша для людини, ніж сухий дамп:
Цей формат оптимізовано для перших хвилин аналізу інциденту в продакшені. Натомість JSON-формат забезпечує безшовну інтеграцію зі стеком моніторингу, перетворюючи сирі логи на структуровані дані для зручної фільтрації, аналізу та візуалізації.
Чому макроси були правильним рішенням
Цікавість nx-error полягає не просто у використанні макросів, їх використовують багато крейтів, а в застосуванні процедурних макросів для централізації конвенцій: генерація конструкторів, дефолтні метадані, ергономіка API.
Цінність макросу тут — не саме метапрограмування, а здатність примусово забезпечити узгодженість у всіх крейтах проєкту.
Коротке порівняння з альтернативами
Часте запитання: чому б просто не поєднати thiserror, anyhow та власний мапер відповідей? Для багатьох проєктів це дійсно правильний шлях. Але для NEXUS порівняння виглядало так:
Справа не в тому, що якийсь підхід універсально кращий. Вони просто оптимізують різні метрики. NEXUS потребував крейту, який виступає спільним контрактом збоїв між усіма шарами.
Чому nx-error з’явився першим
nx-error проєктувався як базовий шар контракту для всього NEXUS. Майже кожен інший крейт у системі залежить від правильної семантики збоїв. Як тільки я почав ставитись до помилок як до частини контракту платформи, а не як до локальної деталі імплементації, порядок розробки став очевидним. Перший крейт у такій системі має зменшувати ентропію для всього, що будуватиметься поверх нього.
Практичні приклади
Приклад 1: Доменна помилка зі стабільною семантикою API
Приклад 2: Обгортання інфраструктурних збоїв із збереженням джерела
Приклад 3: Додавання підказок там, де вони доречні
Як створення nx-error змінило мій погляд на обробку помилок
Головний висновок: обробка помилок — це не другорядна задача в системному софті. Вона одночасно формує чотири аспекти системи:
- модель рантайму
- контракти API
- стратегію спостереджуванності
- досвід інфраструктурних інженерів
Хороша абстракція має зробити всі чотири аспекти більш узгодженими. Для NEXUS це означало, що nx-error має робити значно більше, ніж просто імплементувати Display та Error.
Крейт мав стати мостом між типізованим кодом Rust, безпечними відповідями для клієнтів і багатими діагностичними даними для девопсів.
Підсумки
nx-error з’явився у відповідь на досить специфічний набір обмежень: типізовані доменні помилки, стабільні машиночитні метадані, безпечна зовнішня серіалізація, багата внутрішня діагностика та передбачувана поведінка у багаторівневих Rust-сервісах.
Ці вимоги підштовхнули дизайн до моделі, заснованої на метаданих та підтримуваної макросами. Мета полягала не в тому, щоб винайти нову філософію обробки помилок, а в тому, щоб зменшити обсяг рутинної роботи та втрату контексту, що завжди трапляється, коли системи виростають за межі кількох модулів.
Як перший крейт у NEXUS, nx-error задав тон усьому проєкту: робіть контракти явними, зберігайте спостережуваність за збоями та уникайте плати за складність там, де рантайм не отримує від неї жодної користі.
Ресурси
У наступній частині я заглиблюся в інші аспекти розвитку проєкту.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівНажаль на Расті не працював досі, але радий бачити на доу більш глибокий матеріал! Хочеться щоб таких ставало більше, дякую
Дякую за відгук! Моєю головною метою якраз і було змістити фокус із мовних особливостей Rust на архітектурні трейд-оффи.
З точки зору канонів Clean Code тут є суттєві порушення. Проте в хайлоаді інженерний прагматизм завжди б’є книжкові догми. Цей цикл статей саме про те, як іти на такі компроміси: чому рішення, які суперечать теорії, часто рятують у продакшені.
Будь-яка архітектура — це компроміси, де потрібно чітко зважувати профіт і наслідки. Тому для мене набагато важливіше показати не як написати код, а з якими викликами я зіткнувся і чому прийняв саме такі рішення.