open atlas
↑ К треку
Go с нуля до senior GO · 05 · 04

Ошибки как API: sentinel-ы, типы, контракт Is/As и %w, который сливает ваши внутренности

Ошибки — часть публичного API: sentinel, типизированные и опаковые; контракт errors.Is/As, который вы обещаете, дисциплина оборачивания на границах пакетов — %w публикует всю цепочку — и стабильность ошибок между версиями, чтобы потребители не парсили текст.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

В release notes платёжного клиента v1.7.3 значился один пункт: улучшены сообщения об ошибках. Редакторская правка — upstream timeout стал upstream deadline exceeded, яснее и точнее. Через четыре часа после апгрейда у команды-потребителя умер ночной джоб расчётов с тремя тысячами необработанных переводов. Их слой ретраев решал, что ретраить, вот так: strings.Contains(err.Error(), "timeout"). Новая формулировка сделала каждый временный сбой похожим на постоянный, джоб перестал ретраить, и очередь молча копилась до утреннего алерта. Неловкий вывод постмортема: обе команды виноваты пополам. Потребитель парсил текст ошибок потому, что три мажорные версии назад клиент не экспортировал ничего другого — ни sentinel, ни типа, только fmt.Errorf с прозой. От чего бы ни зависели ваши ошибки снаружи — кто-то на это завязан; закон Хайрама не делает исключений для частей, которые вы считали косметикой. Если не проектировать API ошибок намеренно, вашим API становятся сообщения в логах — и редакторская правка превращается в breaking change.

К концу урока ты будешь точно знать, что пообещал в момент, когда написал return fmt.Errorf("...: %w", err) на границе пакета, — и что делать вместо этого.

Три вида ошибок, которые можно пообещать

Ошибка, возвращаемая экспортированной функцией, — такой же API, как остальные её результаты, и предложить можно ровно три контракта. Sentinel (дозорное значение — экспортированная переменная с фиксированной идентичностью) — экспортированное значение уровня пакета: io.EOF, sql.ErrNoRows, ваш ErrNotFound — обещает одну сравнимую идентичность, которую потребители проверяют через errors.Is. Дёшево в предоставлении, но не несёт данных и остаётся навсегда: его нельзя раз-экспортировать, и каждый путь кода, который его возвращает, прибит к этому смыслу. Типизированная ошибка — экспортированная структура или интерфейс, реализующий error: *fs.PathError, ваш *RateLimitError — извлекается через errors.As и несёт структурные поля вроде RetryAfter. Самый богатый контракт и самый дорогой: его поля и семантика версионируются, как любая публичная структура. Опаковая ошибка не обещает ничего, кроме не-nil — потребитель может её залогировать, но не может на ней ветвиться. Опаковость — тот дефолт, за который стоит драться: каждый экспортированный sentinel и тип — поверхность, которую вы поддерживаете между версиями, поэтому вопрос дизайна никогда не «какие у меня есть ошибки», а «какие решения должны принимать мои вызывающие». Тем, кому нужен только успех/провал, — опаковая; тем, кто ветвится (ретраить? 404? какой бэкофф?), — минимальный sentinel или тип, поддерживающий ветку. Полезный средний путь — поведенческий интерфейс (behaviour interface): экспортировать только проверку способности в духе interface{ Timeout() bool }, позволяя задать вопрос без привязки к конкретной идентичности. Когда проектируешь новую экспортированную функцию, спроси себя: какое решение моему вызывающему реально нужно принять? Этот вопрос почти всегда сам указывает, какая из трёх форм нужна.

errors.Is и errors.As — контракт; оборачивание — водопровод

С Go 1.13 fmt.Errorf с %w записывает обёрнутую ошибку, а errors.Is/errors.As обходят получившуюся цепочку: Is сравнивает идентичности звено за звеном, As находит первое звено, присваиваемое целевому типу. Именно этот обход вы обещаете, когда документируете «возвращает ErrNotFound, если не найдено», — а не err == ErrNotFound, который ломается, как только кто-нибудь добавит контекст через %w посередине. Полный контракт в коде:

// Пакет user — API ошибок: спроектирован, задокументирован, протестирован.
var ErrNotFound = errors.New("user: not found") // sentinel: экспортирован навсегда

type RateLimitError struct { // типизированная: несёт данные для ветвления
	RetryAfter time.Duration
}

func (e *RateLimitError) Error() string {
	return fmt.Sprintf("user: rate limited, retry after %s", e.RetryAfter)
}

// Сторона потребителя — единственные две проверки, которых должна требовать дока:
if errors.Is(err, user.ErrNotFound) { /* 404, без ретрая */ }

var rl *user.RateLimitError
if errors.As(err, &rl) { backoff(rl.RetryAfter) }

Внутри своего модуля оборачивайте свободно — fmt.Errorf("load profile %s: %w", id, err) добавляет контекст, делающий логи отлаживаемыми, сохраняя работу Is/As сквозь цепочку. Дисциплинарный пункт: оборачивание — это транзитивная видимость: всё, что достижимо по звеньям %w, потребитель может тестировать — ровно об этом следующий раздел.

Викторина

errors.Is(err, user.ErrNotFound) возвращает true. Что именно гарантировано?

Дисциплина оборачивания на границах: %w сливает внутренности

Вот ловушка senior-уровня. Ваш слой хранилища делает return fmt.Errorf("get user: %w", sql.ErrNoRows), и ошибка пересекает границу пакета. Вы только что необратимо опубликовали, что ваш пакет работает поверх database/sql: любой потребитель теперь может написать errors.Is(err, sql.ErrNoRows), какой-нибудь потребитель со временем напишет (снова Хайрам), и ваша миграция на кеш или другой драйвер станет его breaking change — зависимость, которую вы не объявляли ни в одном интерфейсе. Отсюда правило: на границе пакета ошибки переводят, а не пробрасывают. Отобразите внутренние сбои на свой опубликованный словарь; оборачивайте %w свой sentinel, а внутреннюю причину запечатывайте %v — текст для логов сохраняется, цепочка обрезается:

func (s *Store) Get(ctx context.Context, id string) (*User, error) {
	u, err := s.db.GetUser(ctx, id)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		return nil, fmt.Errorf("get user %s: %w", id, ErrNotFound) // ваш словарь
	case err != nil:
		return nil, fmt.Errorf("get user %s: %v", id, err) // %v запечатывает: текст есть, цепочки нет
	}
	return u, nil
}

Выбор между %w и %v — таким образом, решение об API, а не предпочтение форматирования: %w означает «я поддерживаю Is/As по всему, что за этим звеном, навсегда»; %v — «контекст для людей, без контракта». Мульти-оборачивание Go 1.20 (errors.Join, несколько глаголов %w) расширяет ту же дверь — каждая присоединённая ошибка достижима для Is/As, — так что правило границы применяется к каждой ветви. Внутри модуля — по умолчанию %w; на экспортированной поверхности — по умолчанию перевод.

Стабильность между версиями: текст ошибки — не API, но кто-то думает иначе

Контракт, который стоит документировать и тестировать, невелик: какие существуют sentinel-ы, какие типы, какие функции могут их вернуть — в doc-комментариях на каждом (конвенция // Get возвращает ошибку, удовлетворяющую errors.Is(err, ErrNotFound), когда… проверяется машинно в тесте). А дальше держите оборону на том, что контрактом не является: текст сообщений. Вы не запретите потребителю матчить строки — у потребителя из Hook были причины, даже приличные, — но вы можете сделать матчинг текста ненужным (каждая задокументированная ветка достижима через Is/As) и можете отказаться считать формулировки замороженными. Правила версионирования, следующие из механики: удалить или переименовать sentinel, либо сузить случаи его возврата — мажорный брейк; добавить поле в типизированную ошибку — можно, поменять смысл поля — нельзя; перевести путь с возврата вашего sentinel-а на оборачивание чужого — снова утечка. И напишите тест, пришпиливающий обещание — errors.Is(Get(ctx, missing), ErrNotFound) через вашу настоящую цепочку обёрток, — потому что цепочка собирается вызовами fmt.Errorf, рассыпанными по кодовой базе, и один коллега, «улучшивший» %w до %v (или наоборот), молча переписывает ваш публичный API.

Викторина

Пакет store оборачивает sql.ErrNoRows через %w и возвращает через свою экспортированную границу. Что пакет только что пообещал?

Вспомните перед уходом
  1. 01
    Сравни sentinel, типизированные и опаковые ошибки как контракты API: что каждый обещает, чего стоит между версиями и когда какой выбирать.
  2. 02
    Почему выбор между %w и %v на границе пакета — решение об API, и как выглядит паттерн перевода на границе?
Итог

Ошибки пересекают границу вашего пакета — значит, они ваш API; вопрос лишь в том, проектируете вы эту поверхность или она нарастает сама. В словаре три статьи. Sentinel-ы: экспортированные значения вроде ErrNotFound, одна идентичность, проверяемая errors.Is, без данных и навсегда. Типизированные ошибки: экспортированные структуры вроде *RateLimitError, несущие поля через errors.As, версионируемые как любой публичный тип. Опаковые: не-nil и ничего больше — правильный дефолт, потому что каждая экспортированная идентичность — это вечная поддержка; экспортируйте ровно то, чего требуют решения вызывающих, плюс поведенческие интерфейсы, когда проверка способности лучше идентичности. Механическое обещание — обход цепочки Is/As по звеньям %w — никогда ==, никогда текст, — задокументированное на каждой функции и пришпиленное тестом через настоящую цепочку. Дисциплина живёт на границах: %w — транзитивная видимость, и оборачивание чужого sentinel-а через экспортированную поверхность публикует вашу реализацию — потребители начнут проверять errors.Is(err, sql.ErrNoRows), и ваша миграция на кеш станет их аварией. Вместо этого переводите: известные причины становятся вашими sentinel-ами через %w, неизвестные запечатываются %v — текст для логов остаётся, цепочка обрезается; errors.Join из Go 1.20 расширяет достижимость, так что правило покрывает каждую присоединённую ветвь. Стабильность следует той же логике — удалить sentinel или сузить его применение — мажорный брейк, добавить поля типизированной ошибке — нет, — а текст сообщений контрактом не бывает, хотя авария расчётов из Hook показывает: потребители сочтут его контрактом, если не дать ничего лучше. Результат урока — чеклист: опубликованный словарь, задокументированные обещания Is/As, перевод на границе и тест, падающий, когда кто-то меняет глагол. Теперь, когда увидишь return fmt.Errorf("...: %w", sqlErr) на границе пакета, сразу спроси: входит ли sqlErr в мой публичный словарь? Если нет — берёшь %v и переводишь.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.