Go вернул 200 с ошибкой JSON: готовим ответ до WriteHeader

API вычисляет среднюю стоимость заказа и возвращает JSON. На пустой выборке расчёт однажды дал NaN. json.Encoder обнаружил неподдерживаемое число, обработчик вызвал http.Error с кодом 500, но клиент увидел 200. Причина оказалась в порядке действий: успешный статус был отправлен раньше, чем завершилось преобразование результата в JSON.

Найдите момент, после которого статус уже выбран

В ResponseWriter вызов WriteHeader отправляет итоговый статус, а первая запись через Write неявно выбирает 200, если статус ещё не задан. После этого обычный http.Error не может заменить его на 500. Заголовки тоже нужно установить заранее. Поэтому проверка ошибки Encode после отправленного успешного статуса обнаруживает проблему слишком поздно.

Для небольшого JSON-ответа удобно сначала получить байты через json.Marshal. На этой стадии клиенту ещё ничего не отправлено. Ошибку преобразования можно превратить в нормальный серверный ответ, а успешные байты записать вместе с выбранным Content-Type и статусом. Такой порядок делает результат кодирования частью подготовки ответа.

Воспроизведите сбой числа

В программе один обработчик получает корректную среднюю стоимость, другой — NaN. Обе ветки проходят через общую функцию ответа. Проверка через httptest читает реальный статус, тело и Content-Type, поэтому ошибка не скрывается за успешным выполнением функции. Сохраните код в main.go и запустите go run main.go.

package main

import (
	"encoding/json"
	"fmt"
	"math"
	"net/http"
	"net/http/httptest"
	"strings"
)

func reply(w http.ResponseWriter, v any) {
	b, err := json.Marshal(v)
	if err != nil {
		http.Error(w, "cannot encode response", 500)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(200)
	if _, err = w.Write(b); err != nil {
		return
	}
}
func main() {
	for _, value := range []float64{125.5, math.NaN()} {
		w := httptest.NewRecorder()
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reply(w, map[string]float64{"average": value}) }).ServeHTTP(w, httptest.NewRequest("GET", "/average", nil))
		var result map[string]float64
		err := json.Unmarshal(w.Body.Bytes(), &result)
		if err == nil {
			fmt.Println(w.Code, result["average"], w.Header().Get("Content-Type"))
		} else {
			fmt.Println(w.Code, strings.TrimSpace(w.Body.String()), w.Header().Get("Content-Type"))
		}
	}
}

Первая строка показывает 200 и успешно разобранный JSON со значением 125.5. Вторая — 500, тело без частично отправленного JSON и текстовый Content-Type. Не следует исправлять NaN подстановкой нуля на уровне сериализации: нулевая средняя стоимость и отсутствие наблюдений означают разные вещи. На уровне модели ответа лучше заранее определить nullable-поле или отдельное состояние «нет данных».

Разделите ошибки подготовки и доставки

Буферизация решает ошибку сериализации до начала ответа. Запись уже подготовленных байтов тоже может завершиться ошибкой, например после отключения клиента. Тогда повторный http.Error добавит к уже начатому ответу ещё один фрагмент. В рабочем обработчике такую ошибку записывают в серверный журнал с идентификатором запроса; изменить уже переданный статус нельзя.

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

Общий helper должен принимать уже согласованный публичный объект. Если передать произвольную доменную структуру, случайно добавленное экспортируемое поле может попасть наружу. Отдельный DTO делает набор полей видимым при ревью. Ошибка Marshal также может прийти из пользовательского MarshalJSON, поэтому тест только на NaN стоит дополнить объектом с намеренно возвращаемой ошибкой.

Проверьте middleware, которое пишет заголовки до вызова обработчика. Если оно заранее вызывает WriteHeader(200), подготовка JSON внутри маршрута уже не восстановит контроль над статусом. Аналогично работают ранний Flush и приветственный фрагмент тела. Полезный интеграционный тест запускает всю цепочку middleware и проверяет, что отказ сериализации остаётся 500.

Отдельно проверьте входящий JSON: корректный исходящий ответ не гарантирует принятие ровно одного объекта в запросе. Для этого пригодится проверка конца тела после Decode. Две проверки стоят по разные стороны операции и защищают разные контракты.

Проверьте ответ целиком

Кроме статуса проверьте отсутствие успешных заголовков в ветке ошибки. Например, Location с адресом якобы созданного ресурса или Content-Disposition с именем выгрузки не должны случайно остаться от ранней подготовки. Сначала вычислите результат и подготовьте тело, затем заполните заголовки конкретной ветки. Если обработчик сохраняет данные до кодирования ответа, ошибка сериализации не откатывает commit автоматически. Для операции создания полезен отдельный тест: запись сохранилась, ответ 500, клиент повторил запрос. Он покажет, нужен ли контракт идемпотентности независимо от исправленного JSON helper.

Продолжите до взаимодействия сервисов

Теперь ошибка кодирования не маскируется под успешный ответ. Следующий вопрос сложнее: что делать клиенту, если данные уже сохранены, а сам ответ потерялся. В платном курсе микросервисов на Go вы разберёте неизвестный исход POST, повтор без дублирования результата и транзакции PostgreSQL. Такие сценарии связывают HTTP-контракт с сохранённым состоянием. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.