JavaRush /Курси /Go SELF /Обробка non‑2xx — читаємо тіло, повертаємо осмислену поми...

Обробка non‑2xx — читаємо тіло, повертаємо осмислену помилку

Go SELF
Рівень 58 , Лекція 3
Відкрита

1. Non‑2xx — це частина контракту

Коли ви вперше пишете HTTP‑клієнт, дуже легко подумати так: «якщо щось не так — Do поверне err». Але net/http досить чесний: якщо мережа, з’єднання чи тайм-аути не завадили, то err == nil, і ви отримуєте resp, навіть якщо там 404 або 500. Це не підступність Go, а відображення реальності: сервер відповів, просто за контрактом ця відповідь означає помилку.

Тут корисно згадати філософію Go: помилки — це значення, з якими ми можемо працювати як із даними, а не як із магічними винятками. Тому, коли бачимо non‑2xx, наше завдання — не «панікувати», а зібрати з відповіді максимум корисної інформації і повернути її нагору у вигляді структурованої помилки, яку можна і гарно показати користувачу, і програмно розібрати.

Невелика таблиця, щоб усе стало на свої місця:

Ситуація Що повернув Do Що це означає
Немає мережі / DNS / з’єднання не встановилося
err != nil, resp == nil
Транспортна помилка
Сервер відповів 200/201/204…
err == nil, resp != nil
Успіх за контрактом
Сервер відповів 400/404/409/500…
err == nil, resp != nil
Відповідь отримано, але гілка помилки за контрактом

2. Принцип: прочитати тіло один раз і з лімітом

Коли ми кажемо «прочитати тіло відповіді», звучить просто. Але саме тут новачки часто наступають на граблі: читають resp.Body двічі, намагаються одночасно «і залогувати, і декодувати» або читають гігантський body у пам’ять, бо «ну там же помилка, значить точно маленька»… а потім ловлять OOM на несподіваній HTML‑відповіді проксі на 20 мегабайт.

Нам потрібен практичний і безпечний принцип.

Ми читаємо тіло рівно один раз і робимо це з лімітом, а потім уже працюємо з байтами: намагаємося розпарсити JSON‑помилку, якщо API повертає error envelope, а якщо не вийшло — повертаємо акуратний фрагмент тексту як резервний варіант.

Ця ідея добре поєднується з практиками Go щодо збагачення помилок контекстом і проєктування помилок так, щоб їх можна було перевіряти не за рядками, а за типом і полями.

Формат помилки API

У нашому навчальному домені «tasks API» ми вважаємо, що сервер у разі помилки намагається повертати JSON такого вигляду:

{
  "error": {
    "code": "validation",
    "message": "invalid input",
    "fields": {
      "title": "must not be empty"
    }
  }
}

Навіть якщо ваш реальний сервер поки так не робить, клієнту все одно корисно вміти спочатку спробувати розпарсити структуровану помилку, а в разі невдачі — не впасти, а показати резервний варіант, наприклад plain text або HTML.

DTO для цього опису:

package apiclient

type APIError struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type ErrorEnvelope struct {
	Error APIError `json:"error"`
}

Зверніть увагу: Fields — це зручна штука саме для валідації. Користувачу в CLI можна показати коротко «некоректне введення», а якщо хочеться — доповнити: «title: must not be empty».

Читаємо тіло безпечно: io.LimitReader

Зараз буде важливий момент, який зазвичай виглядає надто простим, щоб бути важливим, але на практиці економить години налагодження. Ми читаємо тіло відповіді з обмеженням, навіть у гілці помилки. Не тому, що ми параноїки (хоча… так), а тому, що мережа і проксі вміють приносити сюрпризи.

Зробімо маленький помічник: прочитати «не більше N байт» і перетворити на рядок. Так, ми будемо читати в пам’ять. У гілці помилки це зазвичай нормально, бо ми і так хочемо «знімок» тексту або JSON. Але ліміт робить рішення безпечним.

package apiclient

import (
	"io"
	"strings"
)

func readBodySnippet(r io.Reader, limit int64) (string, error) {
	b, err := io.ReadAll(io.LimitReader(r, limit))
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(b)), nil
}

Зверніть увагу на сигнатуру: приймаємо io.Reader, а не *http.Response. Це робить функцію універсальнішою й її легше тестувати. Ми ще не обговорюємо тестування клієнта глибоко, але звичку формуємо вже зараз.

Типізована помилка замість парсингу рядка

Дуже частий біль новачка виглядає так: «я повернув fmt.Errorf("bad status: %d", resp.StatusCode)… а як тепер нагорі зрозуміти, що це 404, а не 500?» Відповідь: ніяк, якщо ви сховали важливі дані в рядок.

У Go правильніше зробити помилку типом, тобто структурою, щоб її можна було розпізнати через errors.As і витягнути поля.

package apiclient

import "fmt"

type HTTPStatusError struct {
	StatusCode int
	API        *APIError // nil, якщо структуру помилки не розпарсили
	Body       string    // резервний фрагмент тіла
}

func (e *HTTPStatusError) Error() string {
	if e.API != nil && e.API.Code != "" {
		return fmt.Sprintf("http %d (%s): %s", e.StatusCode, e.API.Code, e.API.Message)
	}
	if e.Body != "" {
		return fmt.Sprintf("http %d: %s", e.StatusCode, e.Body)
	}
	return fmt.Sprintf("http %d", e.StatusCode)
}

Ідея проста: Error() — це людський текст, але дані лежать у полях. Хочете — друкуйте err.Error(). Хочете — програмно дивіться StatusCode і API.Fields.

Парсимо envelope і збираємо *HTTPStatusError

Тепер зберемо «серце» лекції: функцію, яка при non‑2xx акуратно прочитає тіло з лімітом, спробує розпарсити JSON‑помилку і поверне *HTTPStatusError.

Ключовий трюк такий: щоб і розпарсити JSON, і мати резервний рядок, зручніше спочатку отримати []byte, а вже потім працювати з ним.

package apiclient

import "encoding/json"

func parseAPIError(body []byte) *APIError {
	var env ErrorEnvelope
	if err := json.Unmarshal(body, &env); err != nil {
		return nil
	}
	if env.Error.Code == "" && env.Error.Message == "" && len(env.Error.Fields) == 0 {
		return nil
	}
	return &env.Error
}

А тепер — складання помилки:

package apiclient

import (
	"io"
	"net/http"
)

func newHTTPStatusError(resp *http.Response) error {
	const limit = 4096

	b, err := io.ReadAll(io.LimitReader(resp.Body, limit))
	if err != nil {
		return &HTTPStatusError{
			StatusCode: resp.StatusCode,
			Body:       "<не вдалося прочитати тіло помилки>",
		}
	}

	apiErr := parseAPIError(b)
	return &HTTPStatusError{
		StatusCode: resp.StatusCode,
		API:        apiErr,
		Body:       string(b),
	}
}

Тут є тонкість: ми читаємо resp.Body всередині newHTTPStatusError. Отже, викликати її треба в тій гілці, де ви вже вирішили, що це non‑2xx, і далі тіло вам більше не потрібне. І, звісно, resp.Body.Close() має бути зроблено ззовні, через defer, одразу після отримання resp.

3. Вбудовуємо обробку в «скелет запиту»

Тепер зберемо стандартний «скелет» функції, яка робить запит. Для простоти уявімо, що ми пишемо клієнт для POST /api/v1/tasks, який створює задачу і повертає DTO відповіді.

Ми поки що не робимо ідеальний універсальний клієнт на всі випадки життя — нам важливо закріпити розгалуження за статусом і повернення осмисленої помилки.

package apiclient

import (
	"context"
	"encoding/json"
	"net/http"
)

type TaskDTO struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func decodeJSON(resp *http.Response, v any) error {
	dec := json.NewDecoder(resp.Body)
	return dec.Decode(v)
}

func (c *Client) CreateTask(ctx context.Context, title string) (TaskDTO, error) {
	req, err := c.newCreateTaskRequest(ctx, title)
	if err != nil {
		return TaskDTO{}, err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return TaskDTO{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return TaskDTO{}, newHTTPStatusError(resp)
	}

	var dto TaskDTO
	if err := decodeJSON(resp, &dto); err != nil {
		return TaskDTO{}, err
	}
	return dto, nil
}

Тут найважливіше — порядок. Спочатку транспорт (err від Do). Потім defer Close. Потім розвилка: успіх або non‑2xx. І тільки після цього decode успішної відповіді.

4. Як використовувати помилку нагорі

Додаємо контекст операції через wrapping

Якщо ви повернете назовні просто *HTTPStatusError, це вже корисно. Але коли проєкт росте, вам дуже швидко стане замало повідомлення «http 400 (validation): invalid input». Виникає питання: у якій операції це сталося? У створенні задачі? У отриманні списку? У позначенні done?

У Go прийнято додавати контекст через wrapping: fmt.Errorf("створення задачі: %w", err). Це робить помилку читабельною для людини і водночас зберігає можливість розпізнати початковий тип через errors.As.

package apiclient

import "fmt"

func (c *Client) CreateTask(ctx context.Context, title string) (TaskDTO, error) {
	dto, err := c.createTaskRaw(ctx, title)
	if err != nil {
		return TaskDTO{}, fmt.Errorf("створення задачі: %w", err)
	}
	return dto, nil
}

Так, це виглядає як «ще один рядок». Але це той самий рядок, який потім у логах і в діагностиці перетворює «щось упало» на «упало саме створення задачі».

Як розпізнати non‑2xx і гарно показати користувачу

Ми пишемо бібліотечний шар клієнта так, щоб його можна було використовувати і в CLI, і в майбутніх адаптерах. Тому нагорі ми не маємо парсити рядки. Ми робимо так: якщо є помилка, пробуємо errors.As до *HTTPStatusError, і якщо це спрацьовує, витягуємо деталі.

package main

import (
	"errors"
	"fmt"
	"os"

	"example.com/taskcli/apiclient"
)

func printError(err error) {
	var st *apiclient.HTTPStatusError
	if errors.As(err, &st) {
		fmt.Fprintln(os.Stderr, "запит не вдалося виконати:", st.StatusCode)

		if st.API != nil && st.API.Code == "validation" {
			fmt.Fprintln(os.Stderr, "помилка валідації:", st.API.Message)
			for k, v := range st.API.Fields {
				fmt.Fprintf(os.Stderr, "- %s: %s\n", k, v)
			}
			return
		}

		if st.API != nil {
			fmt.Fprintln(os.Stderr, "помилка API:", st.API.Message)
			return
		}
	}

	fmt.Fprintln(os.Stderr, "помилка:", err)
}

Так, тут є маленький список із -, і це той рідкісний випадок, коли списки справді підвищують читабельність: валідаційні помилки за полями зручно показувати по одному рядку на поле. В іншому ми тримаємо стиль плавним.

Нюанси: тіло — потік, ліміт обов’язковий

Є два нюанси, які важливо проговорити словами, бо код можна випадково переписати й зламати сенс.

Перший нюанс у тому, що resp.Body — це потік, і якщо ви його прочитали в newHTTPStatusError, то далі він порожній. Це нормально: у гілці помилки нам і не треба читати його повторно. Але якщо у вас є звичка «спочатку логувати тіло, потім намагатися decode», то логувати потрібно з тих самих байтів, які ви вже прочитали, а не читати з resp.Body вдруге.

Другий нюанс у тому, що ліміт — це не «опціональна фіча». Без ліміту ви потенційно читаєте в пам’ять довільний обсяг даних. Іноді сервер повертає детальну HTML‑сторінку помилки, іноді проксі додає свої полотна, іноді API може помилково повернути великий об’єкт. Ліміт перетворює ваш клієнт із «може впасти від несподіванки» на «переживе сюрприз і поверне хоча б шматочок сенсу».

Мінісхема обробки результату Do

Щоб закріпити порядок дій, корисно уявити це як невелику блок-схему. Її зручно тримати в голові, доки не сформується автоматизм.

flowchart TD
    A["client.Do(req)"] --> B{err != nil?}
    B -- так --> C[Помилка транспорту: повернути err]
    B -- ні --> D["defer resp.Body.Close()"]
    D --> E{StatusCode 2xx?}
    E -- так --> F[Декодувати успішний JSON]
    E -- ні --> G[Прочитати тіло з лімітом]
    G --> H{Розпарсити error envelope?}
    H -- так --> I[Повернути *HTTPStatusError з полями API]
    H -- ні --> J[Повернути *HTTPStatusError з фрагментом тіла]

5. Типові помилки під час обробки non‑2xx

Помилка №1: очікувати, що 404/500 прийдуть у err від client.Do.
Так майже ніколи не буває: err — це про транспорт, тобто про те, що запит не вдалося виконати, а 404/500 — це нормальна HTTP‑відповідь. Якщо ви не перевіряєте resp.StatusCode, то вважатимете помилку успіхом і намагатиметеся декодувати «успішний DTO» з тіла помилки.

Помилка №2: не читати тіло відповіді на non‑2xx і повертати «просто bad status».
Технічно ви «обробили» помилку, але фактично викинули найкорисніше: повідомлення сервера, код помилки, поля валідації. Потім користувач побачить «400», а ви гадатимете: це некоректний JSON? Не вистачає поля? Не той URL? Читання тіла помилки розв’язує це одразу.

Помилка №3: читати тіло помилки без ліміту.
Коли здається «та там же помилка, вона маленька», зазвичай саме в цей момент і прилітає HTML‑простирадло від проксі або 10‑мегабайтний JSON, і ваш клієнт починає витрачати пам’ять і час на те, що користувач усе одно не прочитає. io.LimitReader робить поведінку передбачуваною.

Помилка №4: читати resp.Body двічі, наприклад «для логів» і «для decode».
resp.Body — потік. Прочитали один раз — і він закінчився. Якщо хочете і логувати, і парсити, спочатку прочитайте в []byte з лімітом, а далі працюйте вже з байтами: логуйте рядок із них і робіть json.Unmarshal із них же.

Помилка №5: ховати важливі дані в рядок err.Error() і потім намагатися «розпарсити текст».
Якщо ви повертаєте fmt.Errorf("bad status %d", code), нагорі вже не можна надійно зрозуміти, що це саме 404, і тим більше не можна витягнути fields валідації. Типізована помилка HTTPStatusError розв’язує це: дані лежать у полях, а рядок лишається для людини.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ