Deep Engineering
Средний·Опубликовано·25 МИН

Ошибки в Go: одна буква, которая рвёт цепочку, и утверждение типа, которое перестанет работать

Собеседование идёт лестницей: что такое error — чем %w отличается от %v — почему == не работает после обёртки — чем Is отличается от As — что делает Join — и сколько всё это стоит. Замерено: errors.Is на цепочке из двадцати обёрток дороже в 8,2 раза, а сама обёртка — это два выделения и 162 нс на пути ошибки.

Полное техническое изложение

TL;DR

Ошибка в Go — обычное значение, которое возвращают наружу. Никакого отдельного механизма с прыжком через стек нет: функция отдаёт ошибку последним результатом, вызывающий либо разбирается с ней сам, либо добавляет к ней контекст и передаёт выше, а кто-то наверху решает, что с ней делать. error — обычный интерфейс с одним методом; всё остальное — соглашения и пакет errors.

Главное следствие: всё зависит от того, сохранился ли путь к исходной ошибке. %w сохраняет ссылку на неё, %v — нет; разница в одну букву, сообщение в логе одинаковое, а errors.Is даёт true и false соответственно. По той же причине == не разворачивает обёртку: после fmt.Errorf("...: %w", err) сравнение с исходной ошибкой даёт false. А утверждение типа смотрит только на верхний уровень: на одной и той же ошибке errors.As даёт true, а err.(*NotFoundError)false, и такой код работает ровно до чужой правки, добавившей обёртку уровнем выше. Оборачивать при этом — значит брать обязательство: обёрнутая ошибка становится частью интерфейса пакета, по ней начнут проверять.

Дальше — граничные случаи и цена. errors.Join — это дерево, а не обёртка: Is находит любую из объединённых ошибок, а одноместный Unwrap для него не определён и возвращает nil. Обёртка стоит 162 нс и два выделения, но платится только на пути ошибки; проверка платится всегда и растёт с глубиной — errors.Is на цепочке из двадцати обёрток дороже в 8,2 раза, чем на одной. Числа сняты в одном прогоне на одной машине.

Порог входа
Перед уроком достаточно понимать
  • функция может закончиться неудачей, и вызывающему надо об этом узнать;
  • в Go функция возвращает несколько значений, и последним из них обычно идёт признак неудачи;
  • интерфейс — это требование к поведению: тип подходит, если у него есть нужный метод.
Заранее знать не нужно
  • что такое обёртка ошибки, цепочка и разворачивание;
  • %w, errors.Is, errors.As, errors.Join, sentinel-ошибки, типизированный nil.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Что такое error в Go?» — разминка: интерфейс с одним методом.
  2. «Чем %w отличается от %v — здесь отваливается первая половина.
  3. «Почему err == ErrNotFound перестало работать?» — про обёртку.
  4. «Is или As — и почему утверждение типа хуже обоих.
  5. «Что такое sentinel-ошибка и когда её не надо?» — про обязательства.
  6. «Сколько это стоит?» — вопрос на честность.

Дальше урок идёт по этой лестнице. Спина у неё одна: обёртка — это ссылка вниз, и всё поведение зависит от того, есть ли она.

База: ошибка — это значение, которое передают выше

В Go нет отдельного механизма отказа: неудача сообщается обычным значением. Функция, которая может не справиться, возвращает его последним результатом, и дальше всё делается вручную — оттого поток работы с ошибкой виден целиком.

Выглядит он так, и других участников в нём нет:

  1. функция обнаружила неудачу и вернула ошибку вызывающему;
  2. вызывающий проверил её и решает: разобраться самому или передать выше;
  3. если передавать — он добавляет контекст: где и зачем он это делал, — и возвращает результат дальше;
  4. кто-то наверху принимает решение: ответить клиенту, повторить попытку, записать в лог. Решает один, и обычно это обработчик запроса, воркер или main.

Ключевой здесь третий шаг. Ошибка идёт снизу вверх и по дороге обрастает контекстом: open /etc/app.yaml: no such file превращается в load config: open /etc/app.yaml: no such file, а потом и в serve: load config: …. В логе это читается как один путь — от места решения до места отказа.

И вот главный вопрос урока: что происходит с исходной ошибкой, когда к ней добавляют контекст? Наверху ведь надо не просто напечатать текст, а понять, что случилось: «файла нет» — это одно решение, «нет доступа» — другое. Значит, добавление контекста обязано сохранить то, по чему наверху будут различать.

Ответ такой: новая ошибка может держать ссылку на старую, а может не держать. В обоих случаях в логе получается одна и та же строка — и это единственная разница, из которой вырастает вся остальная тема.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: ошибка — это значение, его возвращают, по дороге вверх к нему добавляют контекст, а наверху классифицируют причину. Дальше — про то, чем добавляют контекст, как классифицируют, что происходит, когда отказов сразу несколько, и во что всё это обходится.

Механизм 1: три задачи, которые решает error

У работы с ошибками ровно три задачи, и почти вся путаница берётся из того, что их не разделяют:

задачачем решается
вернуть ошибку вызывающемутип с методом Error() string
добавить контекст, не потеряв причинуfmt.Errorf с %w
классифицировать причину у вызывающегоerrors.Is и errors.As

Каждая следующая строка опирается на предыдущую: классифицировать можно только то, что не потеряно при добавлении контекста. Урок идёт по этим трём задачам.

контракт языкаГарантии языка и стандартной библиотеки. Числа и цена — отдельный разговор: они из замеров, а не из документации.

Начнём с первой, и она короче всех:

GO
type error interface {
    Error() string
}

Всё. Отсюда сразу три следствия, которые стоит назвать:

  • Ошибка — обычное значение. Её можно положить в переменную, сравнить, передать, вернуть — и она ничем не отличается от других значений.
  • Любой тип с методом Error() string — ошибка. Никакой регистрации не нужно.
  • error — интерфейс, а значит, на него распространяется ловушка типизированного nil. Функция, объявленная возвращающей error, но возвращающая переменную конкретного типа, вернёт «ошибку» и тогда, когда её нет.

Про оформление есть соглашение, которое проверяют на ревью:

Error strings should not be capitalized (unless beginning with proper nouns or acronyms) or end with punctuation, since they are usually printed following other context.

перевод

Строки ошибок не должны начинаться с заглавной буквы (если только это не имена собственные или аббревиатуры) и не должны заканчиваться знаком препинания, поскольку обычно они печатаются вслед за другим контекстом.

Go Code Review Comments

Отсюда и форма обёртки: fmt.Errorf("load config: %w", err) — глагол и двоеточие, а не законченное предложение. Читается это снизу вверх как один путь: serve: load config: open /etc/app.yaml: no such file.

Механизм 2: одна буква решает всё

Две строки, отличающиеся одним символом:

GO
fmt.Errorf("ctx: %w", err)   // цепочка сохранена
fmt.Errorf("ctx: %v", err)   // цепочка разорвана

Сообщение в логе у них одинаковое. Разница только в том, держит ли новая ошибка ссылку на старую:

If the format specifier includes a %w verb with an error operand, the returned error will implement an Unwrap method returning the operand.

перевод

Если в строке формата есть глагол %w с операндом-ошибкой, возвращаемая ошибка будет реализовывать метод Unwrap, возвращающий этот операнд.

Пакет fmt — Errorf

Переключайте случаи — видно, есть ли под верхней ошибкой ссылка вниз:

Проверено прогоном:

errors.Is(err, base)errors.Unwrap(err)
%wtruebase
%vfalsenil

И сразу третья строка того же прогона: wrapped() == errNotFound даёт false. Сравнение == смотрит на само значение, а после обёртки это уже другое значение. Отсюда правило: sentinel-ошибки сравнивают через errors.Is, а не через == — иначе код ломается от чужой правки, добавившей обёртку двумя уровнями ниже.

Механизм 3: Is и As — это обход цепочки, а не магия

Прежде чем смотреть на сигнатуры, стоит сказать, что эти функции делают, — иначе они запоминаются как две волшебные проверки, которые «как-то умеют».

Обе делают одно: идут по цепочке Unwrap от внешней ошибки к внутренней и на каждом шаге задают свой вопрос.

errors.Is спрашивает «это то самое значение?», errors.As — «это тот самый тип?». Всё остальное — детали: обе останавливаются на первом «да», обе возвращают false, дойдя до конца цепочки.

Отсюда сразу понятно, почему %v из предыдущего раздела ломает обе: без Unwrap цепочки нет, и обходить нечего — первый же шаг упирается в nil.

Разница между Is и As простая, если запомнить, что каждый ищет.

errors.Is ищет конкретное ЗНАЧЕНИЕ — обычно sentinel вроде os.ErrNotExist:

Is unwraps its first argument sequentially looking for an error that matches the second.

перевод

Is последовательно разворачивает свой первый аргумент, ища ошибку, соответствующую второму.

Пакет errors — Is

errors.As ищет конкретный ТИП и заодно достаёт значение, чтобы можно было прочитать поля:

GO
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    log.Println(pathErr.Path)   // ради этого As и нужен
}

А хуже обоих — утверждение типа. Оно смотрит только на верхний уровень:

результат
errors.As(err, &target)true
err.(*NotFoundError)false

Это одна и та же ошибка. Разница в том, что между ней и типом в основании оказались две обёртки. Опаснее всего здесь не сам факт, а то, как он проявляется: код с утверждением типа работает ровно до первой правки, которая добавит обёртку уровнем выше — и перестаёт работать молча, без ошибки сборки и без падения тестов.

Отсюда правило: в обработке ошибок утверждение типа не используют вовсе. Есть errors.As, и он делает то же самое, только по всей цепочке.

Механизм 4: Join — это дерево, а не обёртка

errors.Join появился в Go 1.20 и путается с обёрткой чаще всего.

GO
err := errors.Join(errA, errB)
errors.Is(err, errA)   // true
errors.Is(err, errB)   // true
errors.Unwrap(err)     // nil ← вот здесь ошибаются

Причина в том, что Join реализует Unwrap() []error, а не Unwrap() error. Одноместный errors.Unwrap для него не определён и честно возвращает nil. Разворачивать такую ошибку надо через Is/As, которые умеют обходить дерево.

То же самое, кстати, делает fmt.Errorf с несколькими %w: в документации это сказано прямо — при нескольких глаголах возвращается Unwrap() []error.

Где Join уместен. Там, где ошибок действительно несколько и все они равноправны: проверка конфигурации, разбор формы, закрытие нескольких ресурсов. Там, где ошибка одна, а нужен контекст, — это %w, а не Join.

Механизм 5: оборачивать — значит брать обязательство

Самый неочевидный ответ этой темы, и его стоит уметь дать.

Whether to wrap an error is a decision about whether to expose the underlying error to the caller. Wrapping an error makes it part of your API.

перевод

Оборачивать ли ошибку — это решение о том, раскрывать ли вызывающему нижележащую ошибку. Обёртка делает её частью вашего интерфейса.

Working with Errors in Go 1.13

Читать это надо так: как только вы обернули sql.ErrNoRows через %w, вызывающий может написать errors.Is(err, sql.ErrNoRows) — и он это сделает. Смена базы данных или переход на другой драйвер сломает чужой код, хотя ваша сигнатура не менялась.

If you don't want to commit to supporting the error as part of your API in the future, you shouldn't wrap the error.

перевод

Если вы не готовы поддерживать эту ошибку как часть вашего интерфейса в будущем, оборачивать её не следует.

Working with Errors in Go 1.13

Практическое правило получается такое: внутри пакета оборачивайте %w свободно; на границе пакета решайте осознанно. Наружу обычно выпускают свои sentinel-ошибки (repo.ErrNotFound), а чужие переводят в них — так внутренняя кухня не становится обязательством.

Что выбрать: таблица решения

Четыре способа сообщить о причине, и выбирают между ними не по вкусу:

нужночемцена
вызывающий отличает один известный случайsentinel: var ErrNotFound = errors.New(…)значение становится частью API навсегда
вызывающему нужны подробности причинысвой тип + errors.Asтип и его поля становятся частью API
надо добавить контекст, сохранив причинуfmt.Errorf("…: %w", err)причина становится наблюдаемой частью контракта
надо сообщить о нескольких отказах сразуerrors.Joinдерево вместо цепочки; Is обходит все ветви
причина вызывающему не нужнаfmt.Errorf("…: %v", err)цепочки нет — и это осознанный выбор, а не опечатка

Последняя строка — единственный законный случай для %v, и она объясняет, почему обе формы оставлены в языке. %v означает «текст причины в логе нужен, а программная проверка — нет». Проблема не в самой форме, а в том, что её ставят случайно: в логе обе выглядят одинаково.

Глубже: сколько это стоит

наблюдение замераbench/goerrors, go1.24.7 linux/amd64, два ядра. Числа сняты на конкретной машине; содержательна кратность и то, чего в ней нет.

Числа — из прогона bench/goerrors/internals.go, снятого один раз на одной машине: у вас наносекунды будут своими, а соотношения — те же по знаку.

Обёртка стоит заметно, но платится только на пути ошибки:

времявыделений
вернуть готовую ошибку0,65 нс0
errors.New на каждый вызов29,11 нс
fmt.Errorf с %w162,02 нс2

Сто шестьдесят наносекунд выглядят много ровно до тех пор, пока не сказано, когда они платятся: только когда ошибка уже случилась. На успешном пути ошибок нет, а значит, нет и этой цены.

Вторая строка — отдельный практический вывод: errors.New внутри функции создаёт новую ошибку на каждый вызов. Sentinel объявляют один раз на уровне пакета, и не только ради этих тридцати наносекунд, а чтобы по ней можно было сравнивать.

А вот проверка платится всегда, и растёт с глубиной:

глубина цепочкиerrors.Is
112,79 нс×1,0
532,80×2,6
1055,45×4,3
20105,32×8,2

errors.Is снимает обёртки по одной, поэтому цена почти линейна по глубине. Отсюда практическое: оборачивать стоит там, где добавляется смысл, а не на каждом уровне подряд. Три обёртки вида a: b: c: реальная причина полезны; пятнадцать — это шум в логе и восемь лишних звеньев на каждой проверке.

Как отвечать на собеседовании

Короткий ответ: ошибка в Go — обычное значение, которое возвращают и передают вверх руками. Функция вернула, вызывающий добавил контекст и передал дальше, наверху кто-то один принял решение. Всё остальное в теме — про то, сохранилась ли при добавлении контекста ссылка на исходную ошибку: с ней наверху можно классифицировать причину, без неё остаётся только текст в логе.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «что такое error» отвечайте одной строкой определения. «Интерфейс с методом Error() string; отсюда и то, что на него распространяется ловушка типизированного nil».

Про %w и %v говорите про ссылку, а не про «оборачивает». «%w сохраняет ссылку на исходную ошибку, %v вставляет её текст; сообщение одинаковое, а errors.Is даёт true и false».

Про == назовите последствие. «После обёртки это другое значение, поэтому сравнивают через errors.Is — иначе код ломается от чужой правки двумя уровнями ниже».

Про Is и As скажите, что ищет каждый. «Is — значение, As — тип, и As заодно достаёт поля. А утверждение типа не используют вовсе: оно смотрит только на верхний уровень и молча перестаёт работать после первой же обёртки».

Про Join скажите «дерево». «Он реализует Unwrap() []error, поэтому одноместный Unwrap возвращает nil, а разворачивать надо через Is/As».

Про цену разделите два числа. «Обёртка — сотни наносекунд и два выделения, но только на пути ошибки. А проверка платится всегда и растёт с глубиной: на двадцати обёртках errors.Is дороже в восемь раз».

Дальше спросят

Спросят дальше

Когда заводить sentinel-ошибку, а когда свой тип?

Короткий ответ

Sentinel — когда вызывающему достаточно знать факт: «не найдено», «нет доступа», «уже существует». Одно значение на уровне пакета, сравнение через errors.Is.

Свой тип — когда нужны подробности: какое поле не прошло проверку, какой путь не открылся, сколько осталось попыток. Тогда errors.As достаёт значение, и поля можно прочитать. Ориентир простой: если вызывающий будет писать if errors.Is(...) и на этом всё — хватит sentinel; если после проверки он полезет за данными — нужен тип.

Спросят дальше

Как реализовать Is или As для своего типа?

Короткий ответ

Метод Is(error) bool или As(any) bool на своём типе — их вызовет errors.Is/errors.As при обходе цепочки.

Нужно это редко и в одном характерном случае: когда ошибки «одинаковы по смыслу», но не по значению. Например, HTTPError{Code: 404} может считать себя равной sentinel-у ErrNotFound — тогда вызывающему не нужно знать про коды. Без такого метода errors.Is сравнивает значения обычным ==.

Спросят дальше

Что не так с github.com/pkg/errors сегодня?

Короткий ответ

Ничего фатального, но он больше не нужен для главного: обёртка и разворачивание переехали в стандартную библиотеку в Go 1.13. Его errors.Wrap и Cause решали ровно ту задачу, которую теперь решают %w и errors.Is.

Одну вещь он давал сверх этого — стек вызовов в момент создания ошибки, и её в стандартной библиотеке до сих пор нет. Если стек нужен, его добавляют своим типом ошибки; переносить ради этого весь проект на стороннюю библиотеку сегодня не принято.

Спросят дальше

Как правильно логировать ошибку, чтобы не логировать её трижды?

Короткий ответ

Правило простое: либо обработать, либо вернуть — но не оба сразу. Самая частая беда в логах — одна ошибка, записанная на каждом уровне стека, потому что каждый «на всякий случай» и залогировал, и вернул.

Логирует тот, кто принимает решение: обработчик HTTP, воркер, main. Все промежуточные уровни только добавляют контекст через %w и возвращают дальше. Тогда в логе одна запись, и она содержит весь путь.

Спросят дальше

Что делает errors.Is с nil?

Короткий ответ

errors.Is(nil, nil) даёт true, errors.Is(nil, target) при ненулевом target — false. Специальных ловушек тут нет, но есть смежная: если функция вернула типизированный nil в интерфейсе error, то err != nil истинно, а errors.Is(err, ErrX) даст false — и код пойдёт по ветке «непонятная ошибка».

Отсюда практическое: ловушка типизированного nil в обработке ошибок проявляется не как паника, а как ошибка, которую не удаётся классифицировать.

Спросят дальше

Сколько уровней обёртки — это нормально?

Короткий ответ

Столько, сколько раз добавляется смысл. Ориентир: обёртка полезна, если её текст говорит вызывающему что-то новое о том, где и зачем случилась ошибка. open config: %w полезно; error: %w — нет.

Замер даёт и вторую сторону: на двадцати обёртках errors.Is дороже в 8,2 раза, чем на одной. Это не запрет, а напоминание, что каждое лишнее звено платится при каждой проверке, а проверок обычно больше, чем обёрток.

Частые заблуждения

Утверждение

%w и %v делают одно и то же, просто %w красивее

На самом деле

%w сохраняет ссылку на исходную ошибку, %v вставляет её текст. Сообщение в логе одинаковое, а errors.Is даёт true и false. Одна буква — и цепочки нет, причём в ревью это не видно.

Утверждение

сравнивать sentinel-ошибку можно через ==

На самом деле

Только пока её никто не обернул. После fmt.Errorf("...: %w", err) это уже другое значение, и == даёт false. Поэтому сравнивают через errors.Is — он разворачивает цепочку.

Утверждение

утверждение типа и errors.As — это одно и то же

На самом деле

Утверждение типа смотрит только на верхний уровень: на одной и той же ошибке errors.As даёт true, а err.(*NotFoundError) — false. И ломается это молча: код работает, пока кто-то не добавил обёртку уровнем выше.

Утверждение

errors.Unwrap развернёт любую ошибку

На самом деле

Не развернёт две: сделанную через %v (ссылки нет) и сделанную через errors.Join или несколько %w — там Unwrap() []error, дерево, а не список. В обоих случаях одноместный Unwrap честно вернёт nil.

Утверждение

errors.Join — это способ обернуть ошибку с контекстом

На самом деле

Это способ соединить несколько равноправных ошибок в одну: проверка конфигурации, разбор формы, закрытие нескольких ресурсов. Для «ошибка одна, нужен контекст» есть %w.

Утверждение

обёртки дорогие, лучше возвращать ошибку как есть

На самом деле

Обёртка стоит 162 нс и два выделения, но платится только на пути ошибки: на успешном пути ошибок нет вовсе. Дороже другое — проверка: errors.Is на двадцати обёртках в 8,2 раза дороже, чем на одной, и вот это платится всегда.

Утверждение

errors.New можно вызывать где угодно

На самом деле

Можно, но errors.New внутри функции создаёт новую ошибку на каждый вызов — 29 нс и, что важнее, значение, с которым нельзя сравнить. Sentinel объявляют один раз на уровне пакета именно поэтому.

Утверждение

оборачивать всё через %w — всегда хорошо

На самом деле

Обёртка делает ошибку частью интерфейса пакета: вызывающий начнёт проверять её через errors.Is, и смена библиотеки внутри сломает чужой код. Блог Go говорит прямо: не готовы поддерживать — не оборачивайте. На границе пакета чужие ошибки переводят в свои.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Найдёт ли errors.Is основание после %w и после %v, сработает ли ==, найдёт ли As тип в глубине цепочки и найдёт ли его утверждение типа?
fmt.Println(errors.Is(wrapped(), errNotFound))
fmt.Println(errors.Is(formatted(), errNotFound))

fmt.Println(wrapped() == errNotFound)

var target *NotFoundError
fmt.Println(errors.As(typedWrapped(), &target))
_, ok := typedWrapped().(*NotFoundError)
fmt.Println(ok)

fmt.Println(errors.Unwrap(typedWrapped()))

Практика · оцените

errors.Is ищет одну и ту же ошибку в основании. Во сколько раз это дороже на цепочке из десяти обёрток, чем на цепочке из одной?
раза

Проверка знаний

Вопрос 1 из 6

Функция вернула fmt.Errorf("ctx: %v", ErrNotFound). Что даст errors.Is(err, ErrNotFound) у вызывающего?

Источники и что читать дальше

4 ИСТОЧНИКА

  1. Пакет errors — Is, As, Unwrap, JoinОфициальная документация. Правила, из которых выводится вся тема. Про Is: «Is unwraps its first argument sequentially looking for an error that matches the second» (Is последовательно разворачивает свой первый аргумент, ища ошибку, соответствующую второму). Про As: «As finds the first error in err's tree that matches target, and if one is found, sets target to that error value and returns true» (As находит в дереве err первую ошибку, соответствующую target, и, если такая найдена, присваивает target это значение ошибки и возвращает true). Про Join: «Join returns an error that wraps the given errors. Any nil error values are discarded. The error formats as the concatenation of the strings obtained by calling the Error method of each element of errs» (Join возвращает ошибку, оборачивающую переданные ошибки. Значения nil отбрасываются. Ошибка форматируется как объединение строк, полученных вызовом метода Error у каждого элемента errs).https://pkg.go.dev/errors
  2. Пакет fmt — глагол %wОфициальная документация. Место, где задана единственная разница между %w и %v: «If the format specifier includes a %w verb with an error operand, the returned error will implement an Unwrap method returning the operand. If there is more than one %w verb, the returned error will implement an Unwrap method returning a []error containing all the %w operands in the order they appear in the arguments» (Если в строке формата есть глагол %w с операндом-ошибкой, возвращаемая ошибка будет реализовывать метод Unwrap, возвращающий этот операнд. Если глаголов %w несколько, возвращаемая ошибка будет реализовывать метод Unwrap, возвращающий []error со всеми операндами %w в порядке их появления в аргументах).https://pkg.go.dev/fmt#Errorf
  3. Working with Errors in Go 1.13 — блог GoОфициальная документация. Совет самих авторов о том, когда оборачивать, а когда нет: «Whether to wrap an error is a decision about whether to expose the underlying error to the caller. Wrapping an error makes it part of your API» (Оборачивать ли ошибку — это решение о том, раскрывать ли вызывающему нижележащую ошибку. Обёртка делает её частью вашего интерфейса). И прямо про совместимость: «If you don't want to commit to supporting the error as part of your API in the future, you shouldn't wrap the error» (Если вы не готовы поддерживать эту ошибку как часть вашего интерфейса в будущем, оборачивать её не следует).https://go.dev/blog/go1.13-errors
  4. Effective Go и обзор кода Go — оформление ошибокОфициальная документация. Соглашение об оформлении, которое проверяют на ревью: «Error strings should not be capitalized (unless beginning with proper nouns or acronyms) or end with punctuation, since they are usually printed following other context» (Строки ошибок не должны начинаться с заглавной буквы (если только это не имена собственные или аббревиатуры) и не должны заканчиваться знаком препинания, поскольку обычно они печатаются вслед за другим контекстом). Отсюда и то, что обёртка пишется как «глагол: %w», а не как законченное предложение.https://go.dev/wiki/CodeReviewComments#error-strings