errors

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 4 Imported by: 0

Documentation

Overview

Package errors — pgx-free sentinel error family for kaname.

Every use-case returns a sentinel-family error (ErrNotFound / ErrAlreadyExists / ErrFailedPrecondition / ErrInvalidArg / ErrInternal / ErrUnavailable / ErrPermissionDenied / ErrUnauthenticated / ErrAborted); the handler layer maps to a gRPC code (shared.MapRepoErr). The within-service invariant forbids software-precheck for within-service refs — a within-service violation is detected by catching the pgx SQLSTATE and wrapping it in the appropriate sentinel. That SQLSTATE→sentinel bridge (which needs pgx/pgconn) deliberately lives in the ADAPTER layer (internal/repo/kaname/pg/pgmaperr.go), NOT here: this package stays pgx-free so the ~40 use-case/handler files that import it for the sentinels never pull pgx into their build closure (the dependency rule of clean architecture).

Index

Constants

View Source
const (
	// SerializationConflictSyncText — синхронная полоса. Текст НЕ меняется:
	// он часть контракта и читается существующими клиентами.
	SerializationConflictSyncText = "conflicting concurrent change, retry the request"

	// SerializationConflictTerminalText — терминальный исход операции.
	//
	// Называет ФАКТ прежде совета: сериализационный отказ откатывает транзакцию,
	// поэтому «может быть, применилось» здесь исключено, и сказать это прямо
	// дешевле, чем оставить вызывающего гадать.
	SerializationConflictTerminalText = "conflicting concurrent change, " +
		"the operation was not applied; submit a new request"
)
  • Почему тексты живут ЗДЕСЬ, а не у своих потребителей
  • Почему текстов ДВА, а не один

── Тексты отказа сериализационного конфликта: ОДНО объявление на обе полосы ──

Приёмка: `docs/engineering/acceptance/refusal-text-names-the-lane-that-can-retry.md` (задача `PRO-Robotech/kacho#2439`).

Почему тексты живут ЗДЕСЬ, а не у своих потребителей

Их читают ДВА места: переводчик отказов хранилища (`repo/kaname/pg`), который производит синхронный текст, и надстройка над репозиторием операций (`apps/kaname/shared`), которая подменяет его терминальным. Объявленные литералом у каждого, они разошлись бы при первой же правке — и разошлись бы МОЛЧА: на всяком входе, кроме сериализационного конфликта, обе стороны отвечают одинаково.

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

Почему текстов ДВА, а не один

Совет «повтори» верен ровно там, где повторить может вызывающий: на синхронной полосе он держит соединение, отказ не терминален, повтор того же запроса проходит. На терминальном исходе операции повторять некому — исполнитель повторяет только терминальную ЗАПИСЬ, тело исполняется один раз, — поэтому там текст называет следующий шаг и сверх того факт, который вызывающему нужнее совета: изменение НЕ ПРИМЕНЕНО.

View Source
const StepUpChallengeTrailer = "kaname-step-up-challenge"

StepUpChallengeTrailer — ключ хвостовых метаданных ответа gRPC, которым публичный слушатель передаёт СВОЕМУ REST-фронту готовый вызов указания.

Хвост, а не заголовки: отказ перехватчика уходит ответом без тела, и заголовки, не отправленные до статуса, клиент gRPC не получает вовсе — хвост доезжает всегда. Значение — тот же вызов, что ставится в `WWW-Authenticate`: держатель удостоверения читает его и на нативном gRPC.

View Source
const TextStepUpRequired = "authentication level is insufficient: step up with a second factor, or present a credential of another kind"

TextStepUpRequired — текст указания, дословно один на все поверхности (Р11; тот же, что у края — сторона края kaname#511). Называет ШАГ и ничего не говорит об объекте: КАКОЙ уровень нужен, несёт вызов (`acr_values`).

Variables

View Source
var (
	ErrNotFound           = stderrors.New("not found")
	ErrAlreadyExists      = stderrors.New("already exists")
	ErrFailedPrecondition = stderrors.New("failed precondition")
	ErrInvalidArg         = stderrors.New("invalid argument")
	ErrInternal           = stderrors.New("internal")
	ErrUnavailable        = stderrors.New("unavailable")
	// ErrPermissionDenied — caller is authenticated but lacks the required
	// permission / is not the designated actor (e.g. JitPending
	// non-designated-approver, ComplianceReport FGA gate). Maps to gRPC
	// PERMISSION_DENIED.
	ErrPermissionDenied = stderrors.New("permission denied")
	// ErrUnauthenticated — caller's token does not satisfy the required
	// authentication assurance (step-up acr). Maps to gRPC UNAUTHENTICATED.
	ErrUnauthenticated = stderrors.New("unauthenticated")

	// ErrQuotaExceeded — потолок на число ресурсов вида у носителя достигнут:
	// строка учёта есть, место выбрано. Маппится в RESOURCE_EXHAUSTED (край →
	// HTTP 429) с признаком `QUOTA_EXCEEDED`; администратору требуется ПОДНЯТЬ
	// предел.
	ErrQuotaExceeded = stderrors.New("resource count quota exceeded")

	// ErrQuotaNotProvisioned — потолок не назван НИ НА ОДНОЙ области видимости.
	//
	// Отдельный sentinel, а не оттенок предыдущего, и это не стиль: сведи их в
	// один — и читающий «место кончилось» пойдёт искать, что понизить, там, где
	// ничего не назначено. Маппится в FAILED_PRECONDITION с признаком
	// `QUOTA_NOT_PROVISIONED`: ввод арендатора корректен, не выполнено
	// предусловие ПЛАТФОРМЫ.
	ErrQuotaNotProvisioned = stderrors.New("resource count quota not provisioned")

	// ErrQuotaRateExceeded — потолок на ТЕМП заведения достигнут: за текущее окно
	// принято столько, сколько названо величиной.
	//
	// Отдельный sentinel, а не оттенок `ErrQuotaExceeded`, и различие несущее:
	// отказ по объёму ТЕРМИНАЛЕН (лечится поднятием предела), отказ по темпу
	// ВРЕМЕНЕН (лечится ожиданием следующего окна). Свести их в один — значит
	// отправить арендатора ждать того, что не наступит, либо просить поднять
	// предел, который он не исчерпал. Маппится в RESOURCE_EXHAUSTED (край → HTTP
	// 429) с признаком `QUOTA_RATE_EXCEEDED`.
	ErrQuotaRateExceeded = stderrors.New("admission rate quota exceeded")

	// ErrReferenceMissing / ErrReferenceInUse — ДВЕ ПРОТИВОПОЛОЖНЫЕ стороны
	// одного нарушения ссылочной целостности, и они разведены намеренно.
	//
	// Прежде обе приходили одним текстом «referenced resource not found or still
	// in use». Первая лечится СОЗДАНИЕМ ссылаемого ресурса, вторая —
	// ОСВОБОЖДЕНИЕМ ссылок; вызывающий выбрать не мог, потому что различающего
	// признака у него не было ни в тексте, ни в коде, ни в деталях. Отказ,
	// объединяющий взаимоисключающие причины, следующего шага не восстанавливает
	// by construction: любой выбранный шаг верен ровно в половине случаев.
	//
	// Оба ВЛОЖЕНЫ в ErrFailedPrecondition, а не заменяют его: код отказа не
	// меняется (состояние ресурсов не позволяет операцию — это по-прежнему
	// FAILED_PRECONDITION), меняется только различимость. Признак полосы едет
	// в `google.rpc.ErrorInfo` (`shared.referenceRefusal`), потому что разбор
	// прозы вызывающим запрещён конвенцией.
	ErrReferenceMissing = fmt.Errorf("%w: referenced resource missing", ErrFailedPrecondition)
	ErrReferenceInUse   = fmt.Errorf("%w: resource still referenced", ErrFailedPrecondition)

	// ErrInviteExpired — строка приглашения пережила свой срок (приёмка
	// ID-MAIL-1, MAIL-23). Вложена в ErrFailedPrecondition: строка ЕСТЬ, и
	// вызывающий её назвал верно — не позволяет СОСТОЯНИЕ.
	//
	// ОТДЕЛЬНЫЙ ИСХОД, А НЕ «НЕ НАЙДЕНО», и различие это не косметическое:
	// «не найдено» посылает человека искать то, чего нет, тогда как строка на
	// месте и следующий шаг у него другой — попросить пригласить заново.
	ErrInviteExpired = fmt.Errorf("%w: invite expired", ErrFailedPrecondition)

	// ErrInviteNotVerified — строка приглашения жива, но адрес приглашённого не
	// подтверждён нашей полосой (kaname#456, Р11): приглашение активируется
	// ТОЛЬКО подтверждением. Вложена в ErrFailedPrecondition — строка есть, не
	// позволяет состояние; отдельный признак, а не «не найдено» и не «срок
	// истёк»: вызывающий обязан не читать этот отказ как штатный исход гонки.
	ErrInviteNotVerified = fmt.Errorf("%w: invite address not verified", ErrFailedPrecondition)

	// ErrAborted — a transient concurrency conflict the caller can retry (the
	// operation was aborted, typically a transaction serialization failure).
	// Maps to gRPC ABORTED, the idiomatic "retry the transaction" code — unlike
	// FAILED_PRECONDITION, which tells a well-behaved client NOT to retry.
	ErrAborted = stderrors.New("aborted")

	// ErrSelfRevoke — caller tries to revoke its own cluster admin grant
	// (self-protection). Maps to gRPC FAILED_PRECONDITION with
	// the Kachō text "cannot revoke own cluster admin grant".
	//
	// CHECK constraint cannot express this (constraint doesn't know caller —
	// runtime-property), so the guard lives in the SQL WHERE-clause of
	// the CAS UPDATE in ClusterAdminGrantWriter.Revoke.
	ErrSelfRevoke = stderrors.New("self revoke forbidden")

	// ErrLastAdmin — caller tries to revoke the last remaining active
	// cluster admin (lock-out protection). Maps to gRPC
	// FAILED_PRECONDITION with the Kachō text "cannot revoke last active
	// cluster admin".
	//
	// Implemented via single-statement CAS UPDATE with subquery
	// `(SELECT count(*) FROM cluster_admin_grants WHERE granted_until IS NULL) > 1`
	// — atomic, no separate SELECT-then-UPDATE race window.
	ErrLastAdmin = stderrors.New("last admin revoke forbidden")

	// ErrMembershipCarriesRights — членство снимают, пока на него опирается живая
	// выдача. Отложенный триггер `membership_carrying_rights_is_kept` (миграция
	// 472002) отвергает это НА КОММИТЕ. Маппится в FAILED_PRECONDITION — так
	// называет полосу контракт RemoveFromAccount.
	//
	// # Почему СВОЙ признак, а не общий ErrFailedPrecondition
	//
	// Контракт обещает отказ «с перечисленными выдачами», а перечень добывается
	// ОТДЕЛЬНЫМ чтением: триггер отложенный, и к моменту отказа транзакция мертва
	// — спросить у неё, что помешало, нельзя ни одним запросом. Значит у отказа
	// есть потребитель (use-case исключения), который обязан отличить ЭТУ полосу
	// от всех прочих предусловий, чтобы знать, что именно дочитывать. Общий
	// признак этого не даёт: под ним лежат десятки разных отказов, и «дочитать
	// выдачи» на любом из них было бы догадкой.
	//
	// Признак уезжает и КЛИЕНТУ — токеном `MEMBERSHIP_CARRIES_RIGHTS` в
	// `google.rpc.ErrorInfo` (`shared.membershipRefusal`), поэтому полоса
	// различается машинно и не зависит от языка прозы.
	//
	// # Это СПЕЦИАЛИЗАЦИЯ предусловия, а не полоса рядом с ним
	//
	// `errors.Is(err, ErrFailedPrecondition)` на нём по-прежнему ИСТИНА, и это
	// несущее свойство, а не удобство: на общий признак ветвятся пять мест
	// не-тестового дерева и не одна проба. Заведи полосу рядом — и каждое из них
	// молча перестало бы узнавать этот отказ, причём ни одно не сказало бы об
	// этом: они просто ушли бы в ветку «прочее».
	//
	// Текст СОВПАДАЕТ с общим намеренно. Он служит префиксом, который снимает
	// `StripSentinel`, и собственный текст потребовал бы записи в её перечне —
	// то есть второго места об одном предмете. Наружу этот текст не уезжает
	// никогда: он снимается до отправки.
	ErrMembershipCarriesRights error = specialisedSentinel{/* contains filtered or unexported fields */}

	// ErrUnknownResourceType — регистрация ресурса назвала тип объекта, у
	// которого нет ЖИВОЙ строки в каталоге ресурсов платформы.
	//
	// # Почему это ОТДЕЛЬНАЯ полоса, а не общий неверный аргумент
	//
	// У отказа есть потребитель, и ему мало кода. Путь регистрации отвергает
	// вход по двум разным причинам: грамматика кортежа (пусто, пробел, два
	// двоеточия) и отсутствие референта у типа. Первая — ошибка формы, вторая —
	// утверждение о КАТАЛОГЕ, и вызывающему она говорит другое: «форма верна,
	// такого ресурса у платформы нет; прочти каталог». Поле в отказе называет
	// именно эту полосу, поэтому её надо узнать до того, как статус собран.
	//
	// # Это СПЕЦИАЛИЗАЦИЯ неверного аргумента, а не полоса рядом с ним
	//
	// `errors.Is(err, ErrInvalidArg)` на нём остаётся ИСТИНОЙ намеренно: на общий
	// признак ветвится и `shared.MapRepoErr`, и пробы; завести полосу рядом
	// значило бы молча увести отказ в ветку «прочее» — то есть в непрозрачный
	// INTERNAL вместо названного поля.
	//
	// Текст СОВПАДАЕТ с общим по той же причине, что у соседа выше: он служит
	// префиксом, который снимает `StripSentinel`, и наружу не уезжает.
	ErrUnknownResourceType error = specialisedSentinel{/* contains filtered or unexported fields */}

	// ErrPersonalAccountNameUnavailable — личный аккаунт, заводимый как следствие
	// регистрации (Ф4 Р8), столкнулся по `accounts_name_unique`, и свободного имени
	// в зарезервированном пространстве не нашлось перебором против самого
	// ограничения базы (ban #10). Это НЕВЫПОЛНЕНИЕ, а не занятость адреса: имя
	// выбирает СИСТЕМА, не арендатор, поэтому исчерпание — системный отказ, а
	// зарегистрированный АДРЕС при этом свободен, и отказ занятости (400) солгал бы
	// о нём (Р3).
	//
	// # Это СПЕЦИАЛИЗАЦИЯ уже-существует, а не полоса рядом с ней
	//
	// Нарушение уникальности всё же произошло, поэтому `errors.Is(err,
	// ErrAlreadyExists)` на нём остаётся ИСТИНОЙ: потребитель, различающий лишь
	// «уже существует», продолжает работать. Регистрация проверяет ЭТОТ признак
	// ПЕРВЫМ и по нему классифицирует отказ ПО МЕСТУ — из записи ЛИЧНОГО аккаунта,
	// а не из ключа почты (code-authoring §3.1; register.go refuse).
	ErrPersonalAccountNameUnavailable error = specialisedSentinel{/* contains filtered or unexported fields */}
)

Sentinel error family (parity with services/vpc/internal/repo/helpers.Err*).

Functions

func HeldBy added in v0.6.0

func HeldBy(kind HoldKind) error

HeldBy — полоса отказа снятия, держимого ресурсами вида kind. Ставится там, где отказ рождается: `Wrapf(HeldBy(kind), "<текст>")`.

func NamedHold added in v0.6.0

func NamedHold(text string, holder *Holder) error

NamedHold — окончательный отказ снятия: text — то, что увидит клиент, holder — первый держащий либо nil.

func OnEndedCall added in v0.5.0

func OnEndedCall(ctx context.Context, err error) error

OnEndedCall — отказ хранилища БЕЗ СОБСТВЕННОЙ ПРИЧИНЫ (`ErrInternal`, `ErrUnavailable`), пришедший, когда контекст вызова уже кончился, несёт конец контекста в цепочке (kaname#383, #423).

Пул службы доводит отмену до сервера (`corelib/db.NewPool`), и оператор снимается там строкой состояния 57014 — без ошибки контекста в цепочке. Тот, кто судит отказ по цепочке (мост церемонии фундамента: срок и отмена вызова порта — временная недоступность, прочее — отказ сервера), иначе прочёл бы конец срока вызова поломкой. Различает их ровно один факт — кончился ли контекст, на котором шёл вызов, поэтому ctx — ТОТ контекст, на котором исполнялся вызов хранилища.

Ответ, называющий свою причину (записи нет, ввод негоден, конфликт), о сроке не говорит и возвращается нетронутым; тот же 57014 на живом контексте — собственный потолок оператора — остаётся отказом хранилища.

func StepUpChallenge added in v0.5.0

func StepUpChallenge(required, presented string) string

StepUpChallenge — значение `WWW-Authenticate` указания (RFC 9470 §3, RFC 6750 §3) в форме края (KA1-15):

Bearer error="insufficient_user_authentication",
  error_description="Required ACR <N> for this resource; presented ACR <M>",
  acr_values="<N>"

required и presented — ступени лестницы уровней, уже приведённые вызывающим к числу (незнакомое и пустое — «0»): в значение заголовка не попадает ничего, кроме цифр, и кавычку внутрь параметра подставить нечем.

func StripSentinel

func StripSentinel(err error) string

StripSentinel — extracts the "useful" part of the message (after "sentinel: ") so the handler layer can show the client the canonical Kachō text without the internal prefix (parity with services/vpc/internal/apps/kacho/shared/serviceerr/map.go::stripSentinel).

Имя признака снимается ГДЕ БЫ ОНО НИ СТОЯЛО, а не только в начале

Прежде имя снималось префиксом ВСЕГО сообщения. Вызывающий, добавивший свой контекст перед отказом репозитория (`fmt.Errorf("%w: %s: %w", ErrWriteFailed, name, err)`), ставит свой текст ВПЕРЁД — префикс перестаёт совпадать, и служебное имя остаётся в середине:

moduleroles: writing the declared role failed: vpc.network.admin:
failed precondition: resources: probeWithdrawn is not a live platform resource

Класс отказа уже назван кодом статуса и признаком полосы, поэтому в тексте он лишний и вдобавок сообщает имя внутренней переменной. Чистота текста держалась не свойством функции, а тем, что до сих пор никто не оборачивал (kacho#1889).

Вырезается ровно то, что произвела обёртка признака, а не совпавшие слова

Наивная починка — поиск слов признака по тексту — испортила бы отказ, который называет эти слова ПО ДЕЛУ (`name: "failed precondition: x" is not a valid role name`: строку прислал сам вызывающий). Поэтому место реза берётся из ЦЕПОЧКИ: находится узел, чей собственный `Error()` начинается именем признака, и в сообщении замещается ровно его текст. Узел, которого в сообщении нет (свой `Error()`, не склеивающий вложенное), оставляет текст нетронутым.

func Wrapf

func Wrapf(sentinel error, format string, args ...any) error

Wrapf — standard fmt.Errorf-style wrapper with an explicit sentinel. Use in use-cases: `return errors.Wrapf(errors.ErrNotFound, "Account %s not found", id)`.

Types

type HoldKind added in v0.6.0

type HoldKind string

HoldKind — вид ресурса, держащего снятие. Перечень закрыт и повторяет таблицу Р1 приёмки; порядок видов при охране — у репозитория.

const (
	// HoldAccountsOwned — человек владеет аккаунтами (`accounts.owner_user_id`).
	HoldAccountsOwned HoldKind = "accounts_owned"
	// HoldActiveBindings — на человеке есть действующие выдачи.
	HoldActiveBindings HoldKind = "active_bindings"
	// HoldGroupMembership — человек состоит в группах.
	HoldGroupMembership HoldKind = "group_membership"
	// HoldProjects — в аккаунте есть проекты.
	HoldProjects HoldKind = "projects"
	// HoldServiceAccounts — в аккаунте есть сервисные аккаунты.
	HoldServiceAccounts HoldKind = "service_accounts"
	// HoldGroups — в аккаунте есть группы.
	HoldGroups HoldKind = "groups"
	// HoldCustomRoles — в аккаунте есть свои роли.
	HoldCustomRoles HoldKind = "custom_roles"
)

func HoldKindOf added in v0.6.0

func HoldKindOf(err error) (HoldKind, bool)

HoldKindOf — вид держащего, если err — отказ полосы HeldBy.

type Holder added in v0.6.0

type Holder struct {
	// ResourceType — вид первого держащего в написании metadata
	// (`account`, `access_binding`, `group`, `project`, `service_account`, `role`).
	ResourceType string
	// ResourceID — `id` первого держащего.
	ResourceID string
	// Remaining — число держащих этого вида, не меньше одного.
	Remaining int
}

Holder — первый держащий и число держащих того же вида в момент чтения; то, что уезжает в `ErrorInfo.metadata` (Р3).

func HolderOf added in v0.6.0

func HolderOf(err error) (Holder, bool)

HolderOf — первый держащий отказа NamedHold; ok=false — держащего не названо (его нет либо отказ другой полосы).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL