27.08.2026 Инженерное обновление RU

Ошибка вместо «500»: как сервис научился объяснять, что пошло не так

Обновление про диагностику: сквозная передача ошибок между сервисами, журнал каждого запроса, логи, переживающие перезапуск контейнера, и первый автоматический тест-раннер.

Никита Конкин

Проект Репозиторий Демо
Языковые версии: RU EN

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

Что было не так

Сервис состоит из трёх частей: Java-шлюз java-api и два Python-движка, python-engine (сводные таблицы) и python-xml-engine (генерация XML). Шлюз принимает файл от браузера и передаёт его нужному движку.

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

if (responseCode != 200) {
    throw new RuntimeException("Python service returned status " + responseCode);
}

Текст ошибки терялся на этой строке. Пользователь видел «500 Internal Server Error» независимо от того, была ли причина в структуре файла, в опечатке в названии колонки или в упавшем контейнере. Диагностика сводилась к тому, чтобы воспроизвести случай локально и посмотреть в консоль.

Попутно эта проверка была неверна и по существу: любой код вне диапазона 2xx — включая перенаправления — трактовался одинаково, а 201 считался бы ошибкой.

Контракт ошибки

Ошибка стала самостоятельным типом. DownstreamServiceException хранит исходный HTTP-статус движка и сообщение, извлечённое из его тела:

public static DownstreamServiceException from(
    final int status,
    final String body,
    final String service
) {
    String message = "";
    if (body != null && !body.isBlank()) {
        message = DownstreamServiceException.message(body);
    }
    if (message.isBlank()) {
        message = String.format("%s returned HTTP %d", service, status);
    }
    return new DownstreamServiceException(status, message);
}

Разбор тела устроен без предположений о том, какой именно фреймворк ответил: перебираются ключи detail, error и message — первый из них, оказавшийся строкой, становится сообщением. FastAPI отдаёт detail, собственные обработчики — error; оба варианта обрабатываются одинаково. Если тело нечитаемо, остаётся честная формулировка вида python-xml-engine returned HTTP 503.

Ответ наружу собирает ApiResponse.error(status, message), и статус движка сохраняется:

{
  "error": "None of ['Дисциплины'] are in the columns",
  "status": 500
}

Проверка успешности заодно исправлена на диапазон: responseCode < 200 || responseCode >= 300.

Фронтенд перестал угадывать

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

async function responseError(response, fallback) {
  const body = await response.text();
  if (!body) {
    return fallback;
  }
  try {
    const payload = JSON.parse(body);
    const message = payload.detail || payload.error || payload.message;
    if (typeof message === "string" && message.trim()) {
      return message;
    }

Существенны три детали. Пустое тело возвращает запасной текст, а не пустое окно. Неразобранный JSON не роняет обработчик. И текст вставляется как текст, а не как разметка: сообщение приходит из внешнего сервиса и в конечном счёте из пользовательского файла, поэтому интерпретировать его как HTML нельзя.

Журнал каждого запроса

Оба Python-движка получили middleware, записывающий каждый завершённый запрос:

@app.middleware("http")
async def log_request(request: Request, call_next):
    """Record every completed HTTP request in the event log."""
    started = time.monotonic()
    response = await call_next(request)
    logger.info(
        "%s %s -> %s in %.3fs",
        request.method,
        request.url.path,
        response.status_code,
        time.monotonic() - started,
    )
    return response

Метод, путь, код ответа и длительность. Этого достаточно, чтобы отличить «движок не ответил» от «движок ответил отказом за две десятых секунды» — а именно это различие раньше и не удавалось установить. На стороне Java аналогичную роль выполняет конфигурация logback.xml.

Логи, которые переживают перезапуск

Полезность журнала зависит от того, доживёт ли он до момента, когда его откроют. Контейнеры работают от непривилегированного пользователя, и каталог логов, смонтированный с хоста, ему не принадлежал — запись падала молча.

Решение вынесено в entrypoint, выполняемый до старта приложения:

#!/bin/sh
set -eu

log_dir="${LOG_DIR:-/app/logs}"
mkdir -p "$log_dir"
chown -R appuser:appgroup "$log_dir"

exec su-exec appuser:appgroup "$@"

Каталог создаётся и передаётся во владение, после чего exec заменяет процесс приложением уже под нужным пользователем — root не остаётся в родителях. В Java-образе это su-exec, в Python-образах gosu; в docker-compose монтирование получило флаг :z для систем с SELinux.

Даты без времени

Отдельный дефект касался выгрузки. Excel хранит дату вместе с временем, и в XML попадало значение вида 2001-02-03T14:25:59, которого формат «КиберДиплом» не ожидает. Поля ДатаРожд и ДатаРешенияГэк теперь проходят через нормализацию:

def date_only(value, field_name: str) -> str:
    """Return an Excel date value without its time component."""
    if pd.isna(value):
        return ""
    parsed = pd.to_datetime(value, errors="coerce", dayfirst=True)
    if pd.isna(parsed):
        raise ValueError(f"{field_name} contains an invalid date: {value}")
    return parsed.date().isoformat()

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

Тесты как часть контракта

Всё перечисленное — договорённости, которые легко нарушить незаметно. Поэтому обновление добавляет десять тестовых файлов: на стороне Java они закрывают ApiResponse, DownstreamServiceException и оба контроллера, на стороне Python — конфигурацию логирования, точки входа и генератор XML.

Показательнее прочих тест на даты — он формулирует требование прямо:

"ДатаРожд": [pd.Timestamp("2001-02-03 14:25:59")],
...
assert birth_date == "2001-02-03", "ДатаРожд contains a time component"

Запуск всех наборов сразу выполняет test-all.ps1, поднимающий при необходимости виртуальные окружения; описание контрактов вынесено в TESTING.md. Заодно из репозитория убраны скомпилированные классы target/classes, попадавшие в историю по недосмотру, и добавлен .gitignore.

Выводы

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

Доступность

Проект открыт под лицензией MIT. Диагностику можно проверить на собственном файле: заведомо неверный лист или дата покажут не «500», а конкретную причину.

Теги

программирование java python docker логирование тестирование

Другие обновления по проекту

01.06.2026 Технический разбор Сервис автоматизации приложений к дипломам

Как собрать сервис автоматизации документов из трёх языков: практический разбор

Разбор архитектуры сервиса автоматизации дипломных приложений: Java API, Python-обработка Excel, XML-генерация и Docker Compose.