Приложение может «упасть» по двум совершенно разным причинам: нарушено бизнес-правило или что-то сломалось в инфраструктуре. Перепутать их — значит показать пользователю «500 Internal Server Error» там, где должно быть «Баланс недостаточен», и наоборот.
Ниже — один и тот же платёж по заказу #4021: списать 1500 ₽ при балансе 300 ₽, два способа сообщить об этом.
Нарушение бизнес-правила, отданное как null, доезжает до клиента пятисоткой без единого числа. То же правило, брошенное типизированным исключением, доезжает как 422 с суммами — и пользователь чинит платёж сам.
Два вида ошибок
Доменная ошибка — это ожидаемая ситуация внутри бизнес-логики. Пользователь пытается купить билет, которого нет; счёт уходит в минус; дата в прошлом. Такие ошибки предсказуемы и формируют часть API: клиент должен получить внятный ответ, а не стек вызовов.
Технический сбой — нечто непредвиденное: БД недоступна, истёк таймаут, закончилась память. Приложение не виновато, пользователь тут ни при чём — задача логировать и вернуть нейтральный «что-то пошло не так».
Короткая формула: доменная ошибка = бизнес говорит «нельзя»; технический сбой = окружение говорит «не могу».
Доменные исключения: как объявить
Создайте базовый тип для всех доменных ошибок и выводите из него конкретные случаи:
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) {
super(message);
}
protected DomainException(String message, Throwable cause) {
super(message, cause);
}
}
public final class InsufficientBalanceException extends DomainException {
public InsufficientBalanceException(BigDecimal required, BigDecimal available) {
super("Недостаточно средств: требуется %s, доступно %s"
.formatted(required, available));
}
}
public final class OrderNotFoundException extends DomainException {
public OrderNotFoundException(long orderId) {
super("Заказ #%d не найден".formatted(orderId));
}
}
type DomainError struct {
Message string
Cause error
}
func (e *DomainError) Error() string { return e.Message }
func (e *DomainError) Unwrap() error { return e.Cause }
type InsufficientBalanceError struct{ DomainError }
func NewInsufficientBalance(required, available decimal.Decimal) error {
return &InsufficientBalanceError{DomainError{Message: fmt.Sprintf("Недостаточно средств: требуется %s, доступно %s", required, available)}}
}
type OrderNotFoundError struct{ DomainError }
func NewOrderNotFound(orderID int64) error {
return &OrderNotFoundError{DomainError{Message: fmt.Sprintf("Заказ #%d не найден", orderID)}}
}
export abstract class DomainError extends Error {
protected constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = new.target.name;
}
}
export class InsufficientBalanceError extends DomainError {
constructor(required: Money, available: Money) {
super(`Недостаточно средств: требуется ${required}, доступно ${available}`);
}
}
export class OrderNotFoundError extends DomainError {
constructor(orderId: number) {
super(`Заказ #${orderId} не найден`);
}
}
class DomainError(Exception):
pass
class InsufficientBalanceError(DomainError):
def __init__(self, required: Decimal, available: Decimal) -> None:
super().__init__(f"Недостаточно средств: требуется {required}, доступно {available}")
class OrderNotFoundError(DomainError):
def __init__(self, order_id: int) -> None:
super().__init__(f"Заказ #{order_id} не найден")
Так это выглядит в доменном сервисе, который и бросает ошибку:
public void pay(long orderId, BigDecimal amount) {
Order order = orders.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
Account account = accounts.findByCustomerId(order.customerId());
if (account.balance().compareTo(amount) < 0) {
throw new InsufficientBalanceException(amount, account.balance());
}
account.debit(amount);
}
func (s *PaymentService) Pay(ctx context.Context, orderID int64, amount decimal.Decimal) error {
order, err := s.orders.FindByID(ctx, orderID)
if errors.Is(err, ErrNotFound) {
return NewOrderNotFound(orderID)
}
if err != nil {
return err
}
account, err := s.accounts.FindByCustomerID(ctx, order.CustomerID)
if err != nil {
return err
}
if account.Balance.LessThan(amount) {
return NewInsufficientBalance(amount, account.Balance)
}
return account.Debit(amount)
}
async pay(orderId: number, amount: Money): Promise<void> {
const order = await this.orders.findById(orderId);
if (!order) throw new OrderNotFoundError(orderId);
const account = await this.accounts.findByCustomerId(order.customerId);
if (account.balance.lessThan(amount)) {
throw new InsufficientBalanceError(amount, account.balance);
}
account.debit(amount);
}
async def pay(self, order_id: int, amount: Decimal) -> None:
order = await self._orders.find_by_id(order_id)
if order is None:
raise OrderNotFoundError(order_id)
account = await self._accounts.find_by_customer_id(order.customer_id)
if account.balance < amount:
raise InsufficientBalanceError(amount, account.balance)
account.debit(amount)
Каждое исключение несёт конкретные данные — что именно пошло не так. Это важно: при обработке на границе (контроллер, @RestControllerAdvice) вы сможете сформировать содержательный ответ.
Главный довод за непроверяемые исключения
Кроме удобства есть причина, которая в Spring-приложении решает вопрос: @Transactional по умолчанию откатывает транзакцию на непроверяемом исключении и не откатывает на проверяемом.
То есть доменное правило, выраженное проверяемым исключением, даёт худший из возможных исходов: операция не выполнена (исключение вылетело), а транзакция зафиксирована — с тем, что успело записаться до броска. Данные остаются в промежуточном состоянии, и находят это обычно через недели.
// так получается половина записанного
@Transactional
public void cancel(OrderId id) throws OrderCannotBeCancelledException { // проверяемое
Order order = orders.require(id);
audit.record(id, "cancel-attempt"); // запишется
order.cancel(); // бросит — и транзакция закоммитится
}
В Go такого капкана нет, ошибка — обычное значение: транзакция откатывается там, где функция вернула err:
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
order, err := orders.Require(ctx, tx, id)
if err != nil {
return err
}
if err := audit.Record(ctx, tx, id, "cancel-attempt"); err != nil {
return err
}
return order.Cancel() // вернул ошибку — BeginFunc откатит и запись аудита
})
В Node любое исключение внутри tx.run откатывает транзакцию, различия проверяемых и непроверяемых нет:
await this.tx.run(async () => {
const order = await this.orders.require(id);
await this.audit.record(id, 'cancel-attempt');
order.cancel(); // бросил — транзакция откатится вместе с записью аудита
});
В Python любое исключение внутри with conn.transaction() откатывает транзакцию:
with conn.transaction():
order = orders.require(id)
audit.record(id, "cancel-attempt")
order.cancel() # бросил — транзакция откатится вместе с записью аудита
Поправить можно и вручную (rollbackFor = Exception.class), но проще не создавать проблему: доменные исключения делают непроверяемыми. Тогда откат происходит сам, а сигнатуры методов не обрастают объявлениями. Разбор самого правила откатов — в статье про @Transactional.
Исключение должно нести машинный код
Иерархия сама по себе даёт обработчику только тип. Чтобы в ответ попал устойчивый код ошибки, исключение обязано его нести — иначе всё, что можно собрать, это человеческая строка.
public enum ErrorCode {
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND),
ORDER_CANNOT_BE_CANCELLED(HttpStatus.UNPROCESSABLE_ENTITY),
PAYMENT_LIMIT_EXCEEDED(HttpStatus.UNPROCESSABLE_ENTITY),
LOGIN_ALREADY_TAKEN(HttpStatus.CONFLICT);
private final HttpStatus status;
ErrorCode(HttpStatus status) { this.status = status; }
public HttpStatus status() { return status; }
}
public abstract class DomainException extends RuntimeException {
private final ErrorCode code;
protected DomainException(ErrorCode code, String message) {
super(message);
this.code = code;
}
public ErrorCode code() { return code; }
}
type ErrorCode string
const (
OrderNotFound ErrorCode = "ORDER_NOT_FOUND"
OrderCannotBeCancelled ErrorCode = "ORDER_CANNOT_BE_CANCELLED"
PaymentLimitExceeded ErrorCode = "PAYMENT_LIMIT_EXCEEDED"
LoginAlreadyTaken ErrorCode = "LOGIN_ALREADY_TAKEN"
)
var httpStatus = map[ErrorCode]int{
OrderNotFound: http.StatusNotFound,
OrderCannotBeCancelled: http.StatusUnprocessableEntity,
PaymentLimitExceeded: http.StatusUnprocessableEntity,
LoginAlreadyTaken: http.StatusConflict,
}
type DomainError struct {
Code ErrorCode
Message string
}
func (e *DomainError) Error() string { return e.Message }
export const ErrorCode = {
ORDER_NOT_FOUND: HttpStatus.NOT_FOUND,
ORDER_CANNOT_BE_CANCELLED: HttpStatus.UNPROCESSABLE_ENTITY,
PAYMENT_LIMIT_EXCEEDED: HttpStatus.UNPROCESSABLE_ENTITY,
LOGIN_ALREADY_TAKEN: HttpStatus.CONFLICT,
} as const;
export type ErrorCode = keyof typeof ErrorCode;
export abstract class DomainError extends Error {
protected constructor(readonly code: ErrorCode, message: string) {
super(message);
}
get status(): number {
return ErrorCode[this.code];
}
}
class ErrorCode(Enum):
ORDER_NOT_FOUND = auto()
ORDER_CANNOT_BE_CANCELLED = auto()
PAYMENT_LIMIT_EXCEEDED = auto()
LOGIN_ALREADY_TAKEN = auto()
@property
def status(self) -> HTTPStatus:
return _STATUS[self]
_STATUS = {
ErrorCode.ORDER_NOT_FOUND: HTTPStatus.NOT_FOUND,
ErrorCode.ORDER_CANNOT_BE_CANCELLED: HTTPStatus.UNPROCESSABLE_ENTITY,
ErrorCode.PAYMENT_LIMIT_EXCEEDED: HTTPStatus.UNPROCESSABLE_ENTITY,
ErrorCode.LOGIN_ALREADY_TAKEN: HTTPStatus.CONFLICT,
}
class DomainError(Exception):
def __init__(self, code: ErrorCode, message: str) -> None:
super().__init__(message)
self.code = code
Выигрыш двойной. Обработчику достаточно одного метода на все доменные исключения: он берёт код, из кода — статус, и собирает ответ. И каталог кодов оказывается в одном месте — перечислении, из которого можно сгенерировать раздел документации.
Второе, что стоит нести в исключении, — данные для ответа: идентификатор объекта, текущий статус, предел, который превышен. Тогда сообщение собирается в обработчике, а не склеивается строкой в домене (и потому может быть локализовано).
Сколько типов исключений заводить
Первое решение после статьи, и у него два крайних варианта.
Класс на каждое правило (OrderCannotBeCancelledException, PaymentLimitExceededException) — максимальная ясность: по типу видно, что случилось, можно поймать конкретное, в тестах читается. Цена — десятки классов, каждый из которых содержит одну строку смысла.
Один класс с кодом (BusinessRuleException плюс ErrorCode) — компактно, каталог кодов в одном перечислении, обработчик один. Цена — поймать конкретное правило можно только по коду, а не по типу, и это неудобно в тестах и в местах, где нужна реакция на определённое нарушение.
Рабочий компромисс, который встречается чаще всего: отдельные классы для того, на что кто-то реагирует, и общий класс с кодом для остального. То есть «не найдено», «конфликт состояния», «нет прав» — свои типы (на них реагируют и обработчик, и вызывающий код); а конкретные бизнес-правила внутри одного типа с разными кодами. Ориентир простой: заводить свой класс стоит, когда где-то есть catch именно на него.
Спорные случаи: доменная ошибка или технический сбой
Разобранные примеры очевидны («нет денег» против «база упала»). Ошибаются на других, и вот как их различать по одному признаку: может ли клиент что-то сделать с этим ответом.
Таймаут внешнего платёжного сервиса. Технический сбой, но клиенту от этого не легче. Правильный ответ — 503 или 504 с признаком повторяемости: клиент может повторить. А вот если платёжный сервис ответил «отказано банком» — это доменная ошибка (422), потому что повторять бессмысленно, надо менять карту.
Нарушение уникальности при гонке. Технически это исключение уровня доступа к данным, по смыслу — конфликт состояния. Отвечают 409: запрос был правильным, состояние изменилось. Превращать это в 500 — самая частая ошибка, и она делает вид, что виноват сервер.
Конфликт версий при оптимистичной блокировке. То же: 409, повтор осмысленен (перечитать и попробовать снова).
Отказ по бизнес-причине от чужого сервиса. Признак — понятная причина в ответе партнёра («лимит превышен», «адрес не обслуживается»). Такие отказы переводят в свои доменные ошибки с понятными кодами, а не пробрасывают чужие. Иначе контракт вашего API начинает зависеть от того, что придумал партнёр.
Ошибка сериализации ответа. Технический сбой, и его уже нельзя красиво отдать: часть ответа ушла. Единственное лечение — не допускать (не держать логику в геттерах модели ответа).
Логировать 4xx как ошибку не надо
Правило, которому самое место здесь, где вводится деление: доменная ошибка — не повод для записи уровня ошибки и не повод для оповещения. Отказ «нельзя отменить отгруженный заказ» — это штатная работа системы: правило сработало, клиент получил внятный ответ. Если писать такие случаи как ошибки, журнал заполняется шумом, а настоящие сбои в нём теряются.
Раскладка простая. Доменные ошибки (4xx) — уровень отладки или информации, без стектрейса (он ничего не добавляет: причина в правиле, а не в коде). Технические сбои (5xx) — уровень ошибки, со стектрейсом и с оповещением.
И обратная сторона, о которой забывают: раз доменные ошибки не видно в журнале ошибок, их надо считать. Счётчик по коду ошибки в метриках даёт то, чего не даёт журнал: всплеск отказов «карта отклонена» или «лимит превышен» виден на графике, хотя в журнале ошибок тишина. Без этого сломанная интеграция выглядит как затишье.
Почему не null и не коды ошибок
Возвращать null — это молчаливая ошибка. Вызывающий код обязан помнить проверить результат; если забудет — NullPointerException в случайном месте. Исключение же нельзя проигнорировать: оно прервёт выполнение там, где оно возникло.
Коды ошибок (int status, String errorCode в возвращаемом объекте) — это шаблон из эпохи C, когда исключений не было. В языках с исключениями это лишний груз (в Go ошибка как значение — норма языка, но и там её делают типом с данными, а не числом): нужно каждый раз проверять результат, логика «счастливого пути» перемешивается с обработкой ошибок, а тип возврата засоряется служебными полями.
Типизированное исключение прерывает выполнение сразу, а не через несколько вызовов, несёт тип и данные вместо строки с кодом и не требует проверки после каждого вызова.
От исключения до ответа клиенту
Доменное исключение, брошенное в обработчике, «всплывает» до контроллерного слоя и перехватывается глобальным обработчиком (@RestControllerAdvice в Spring, ExceptionFilter в NestJS, exception_handler в FastAPI, общая функция записи ответа в Go). Там оно превращается в структурированный ответ Problem Details (RFC 9457):
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
public ProblemDetail handleInsufficientBalance(InsufficientBalanceException ex) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
problem.setTitle("Недостаточно средств");
problem.setProperty("code", "INSUFFICIENT_BALANCE");
return problem;
}
}
func writeDomainError(w http.ResponseWriter, err error) bool {
var domain *DomainError
if !errors.As(err, &domain) {
return false
}
status := httpStatus[domain.Code]
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"status": status,
"title": http.StatusText(status),
"detail": domain.Message,
"code": domain.Code,
})
return true
}
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
catch(e: DomainError, host: ArgumentsHost) {
host.switchToHttp().getResponse()
.status(e.status)
.type('application/problem+json')
.json({ status: e.status, title: e.name, detail: e.message, code: e.code });
}
}
@app.exception_handler(DomainError)
async def on_domain_error(request: Request, e: DomainError) -> JSONResponse:
status = e.code.status
return JSONResponse(
status_code=status,
media_type="application/problem+json",
content={"status": status, "title": status.phrase, "detail": str(e), "code": e.code.name},
)
Статус ответа Spring берёт из самого ProblemDetail — отдельно помечать метод аннотацией @ResponseStatus не нужно, а если два значения разойдутся, победит то, что лежит в ProblemDetail. Рядом с текстом кладут code — машинный код, по которому клиент ветвит логику, не разбирая сообщение по словам.
Подробно о формате ответа — в статье Ошибки REST API и Problem Details. Здесь нам важен принцип: доменное исключение не обрабатывается внутри бизнес-логики — оно пробрасывается и перехватывается на границе слоя.
Как соотнести ошибки с HTTP-статусами
Доменные ошибки и технические сбои отображаются на разные диапазоны статусов:
| Вид ошибки | HTTP-статус | Пример |
|---|---|---|
| Не найден объект | 404 Not Found | OrderNotFoundException |
| Нарушение бизнес-правила | 422 Unprocessable Entity | InsufficientBalanceException |
| Некорректный запрос | 400 Bad Request | ошибки валидации |
| Технический сбой | 500 Internal Server Error | DataAccessException |
Ключевое правило: 4xx — проблема на стороне клиента (он прислал невалидный запрос или нарушил правило), 5xx — проблема на стороне сервера (инфраструктура упала).
Границу между 4xx и 5xx проводит вопрос, кто может исправить: конфликт состояния это 409, таймаут партнёра 503 или 504, а 500 остаётся только для сбоя в своём коде.
Глубже: какие ошибки можно повторятьрасширенное
Клиент, получивший ошибку, решает одно: повторить или нет. Если модель ошибок не отвечает на этот вопрос, клиенты повторяют всё подряд и устраивают шторм, либо не повторяют ничего и падают на первой сетевой заминке.
Первый ответ даёт статус. 4xx кроме двух исключений повторять нельзя: тот же запрос даст ту же ошибку. Исключения это 429, где сервер просит подождать, и 408 с 409 для конфликта версии, где повтор имеет смысл после перечитывания.
Из 5xx преходящими считают 502, 503 и 504, за ними обычно балансировщик, перезапуск или таймаут, а 500 неоднозначен: это может быть и упавший узел, и ошибка в коде, которая повторится. Заголовок Retry-After при 429 и 503 это прямое указание, сколько ждать, и его уважают вместо своего расписания повторов.
Второй ответ даёт тело. Когда статуса мало, в формат ошибки добавляют признак: расширение retryable: true в теле application/problem+json рядом с code. Так сервер сообщает, что 500 был временным (упала зависимость, сработал предохранитель), а 409 окончательным (заказ уже отменён). Признак ставит тот, кто знает причину, обработчик исключений, по типу исключения: таймаут зависимости и CallNotPermittedException предохранителя дают true, доменные ошибки false.
Третий ответ про безопасность повтора, и он не зависит от статуса. Повторять GET, PUT и DELETE можно всегда, они идемпотентны по определению. POST, создающий заказ или платёж, повторять можно только с ключом идемпотентности из статьи про заголовки: без него ответ «таймаут» не говорит, создался заказ или нет, и повтор создаст второй. Поэтому у операций с деньгами ключ идемпотентности обязателен в контракте, а не рекомендован.
Клиенту это складывается в правило: повторять при 429, 502–504, при сетевом таймауте и при retryable: true, конечное число раз с растущей паузой и разбросом; не повторять 400, 401, 403, 404, 422 и всё с retryable: false; POST без ключа идемпотентности не повторять никогда. Что это значит для отправителя вебхуков, где повторяете уже вы, разбирает статья про вебхуки в разделе REST.
Глубже: ошибки в GraphQL: 200 с массивом errorsрасширенное
Всё, что выше, стоит на HTTP-статусах, а GraphQL их почти не использует, и клиент, который проверяет response.ok, считает любой ответ успешным.
В GraphQL один запрос может вернуть часть данных и часть ошибок одновременно: профиль загрузился, а список заказов упал. Поэтому ответ приходит с 200 и двумя полями: data с тем, что удалось, и errors с тем, что нет, где у каждой ошибки есть message, path до поля, которое не посчиталось, и extensions с машинным кодом:
{
"data": { "customer": { "name": "Иван", "orders": null } },
"errors": [
{ "message": "Сервис заказов недоступен", "path": ["customer", "orders"],
"extensions": { "code": "UNAVAILABLE", "retryable": true } }
]
}
Поле orders стало null, и это допустимо, только если оно объявлено в схеме допускающим null; для обязательного поля null поднимается вверх до ближайшего необязательного предка и затирает больше данных, чем хотелось, поэтому обязательность в схеме GraphQL это решение про то, какие части ответа гибнут вместе.
HTTP-статус остаётся для ошибок транспорта: 400 на синтаксически неверный запрос, 401 на отсутствующий токен, 413 на слишком большой запрос. Всё доменное, NOT_FOUND, FORBIDDEN, VALIDATION, живёт в extensions.code, и это тот же список кодов, что в поле code REST-ошибки, только без статуса рядом. В Spring for GraphQL доменные исключения превращает в такие ошибки DataFetcherExceptionResolverAdapter, где вы сопоставляете исключение с ErrorType и кладёте свои расширения; без него любое исключение уходит клиенту как INTERNAL_ERROR с сообщением по умолчанию. Клиент же обязан смотреть в errors при каждом ответе, а не только при не-200, и это первое, что забывают при переходе с REST.
Коротко
- Разделяй ошибки на доменные (бизнес-правило нарушено) и технические (инфраструктура недоступна) — они обрабатываются по-разному.
- Исключение несёт машинный код (перечисление со статусом) и данные для ответа — тогда обработчику хватает одного метода, а каталог кодов живёт в одном месте; свой класс заводят там, где на него кто-то реагирует.
- Доменные исключения делают непроверяемыми: они не засоряют сигнатуры, а главное —
@Transactionalоткатывает транзакцию только на них, тогда как на проверяемом фиксирует половину записанного. - Не возвращай
nullи коды ошибок — исключение прерывает выполнение немедленно и несёт тип. - Доменное исключение пробрасывается до границы слоя (
@RestControllerAdvice) и там превращается в ответ. - 4xx — ошибка клиента, 5xx — ошибка сервера. Спорные случаи различают по вопросу «что клиент может сделать»: таймаут партнёра
503, отказ банка422, гонка на уникальности и конфликт версий409. - Повторяемость это часть модели:
429и502–504повторяют сRetry-Afterи паузой,4xxнет, спорный500помечают в телеretryable;POSTповторяют только с ключом идемпотентности. - GraphQL отвечает
200сdataиerrorsодновременно; коды вextensions.code,nullдля обязательного поля затирает предка; клиент смотрит вerrorsвсегда. - Доменные ошибки не пишут уровнем ошибки и не оповещают по ним, но обязательно считают счётчиком по коду — иначе сломанная интеграция выглядит как затишье.
Что пощупать
Модель ошибок в практикуме remodov/marketplace-system растёт вместе с сервисами: в стартовом каталоге это ProblemDetail с картой полей, во взрослом каталоге и сервисе заказов — RFC 9457 со стабильным code, по которому клиент пишет switch, и type со ссылкой на описание.
Код: OrderExceptionHandler, ProblemDetailExceptionHandler.
Код на Go: errors.go, problem.go; во взрослом каталоге — apperr.go с видом и кодом ошибки и problem.go, который переводит вид в статус, а код — в type вида urn:problem:catalog:<CODE>. В сервисе заказов к видам добавляется KindUnavailable: apperr.go рождает доменное SERVICE_DEGRADED, когда каталог не отвечает, а problem.go отдаёт его как 503.
Код на Node: product.errors.ts, problem.ts; во взрослом каталоге - apperr.ts с видом и кодом ошибки и problem.ts, который переводит вид в статус, а код - в type вида urn:problem:catalog:<CODE>. В сервисе заказов вид unavailable рождает доменное SERVICE_DEGRADED, когда каталог не отвечает, и уходит как 503: apperr.ts, problem.ts.
Код на Python: errors.py, problem.py; во взрослом каталоге - errors.py с Kind и кодом ошибки и problem.py. В сервисе заказов Kind.UNAVAILABLE рождает доменное SERVICE_DEGRADED и уходит как 503: errors.py, problem.py.
Сделаем сами
Ветка step-08-resilience — доменное «сосед недоступен» ещё не рождается.
На Go: ветки step-03-commands-and-errors и step-08-resilience, где «сосед недоступен» тоже ещё не рождается.
На Node: ветки step-03-commands-and-errors и step-08-resilience, где «сосед недоступен» тоже ещё не рождается.
На Python: ветки step-03-commands-and-errors и step-08-resilience.