---
title: "Данные из сети: HTTP-запросы в командах"
description: "bubbletea http: запросы к API в tea.Cmd — http.Get и разбор JSON в замыкании, состояния loading и error, таймауты через context, оптимистичный UI."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/19-http-dannye/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 20
---

# Данные из сети: HTTP-запросы в командах

*Курс «Bubble Tea: TUI-приложения в терминале», урок 19 из 25. ~20 минут. Обновлено 09.10.2026. Источник: https://golangmaster.ru/courses/bubbletea/lessons/19-http-dannye/*

Списку задач нужен сервер: bubbletea http-запросы выполняет теми же командами tea.Cmd, что и таймеры, — сеть уходит в замыкание, интерфейс продолжает отвечать на клавиши, а в модель возвращается сообщение с данными или ошибкой. Соберём полный цикл: http.Get внутри команды, конечный автомат loading — loaded — error, декодирование JSON, таймауты через context и оптимистичные обновления, без которых терминальный клиент кажется тормозным.

Панель задач без данных — рамка и пустой viewport. Данные лежат на сервере, и тропа к ним начинается с вопроса: где именно выполнять запрос. Механику команд дал пятый урок — функция типа tea.Cmd, которую фреймворк запускает в собственной горутине и чьё возвращаемое значение приходит в Update сообщением. Сеть встраивается в этот механизм без единой новой сущности, но добавляет три условия, которых раньше не было. Медленность: ответ приходит за сто миллисекунд или за десять секунд, и всё это время интерфейс обязан жить. Падучесть: сервер недоступен, отдал 500, вернул кривой JSON — каждое из этих состояний должно быть видно пользователю, а не только в логе. Формат: данные едут JSON, и разбор — часть работы команды, а не Update. Этот урок собирает три условия в каркас: команда с http.Get, три состояния, декодер и таймаут. Проверять его можно только с живой сетью — локальный запуск против настоящего API, потому что песочницы сети не эмулируют. И напоминание из пятого урока, которое в сети бьёт больнее всего: запрос прямо в Update заморозит интерфейс на всё время ожидания — клавиши не работают, q молчит, картинка стоит. Поэтому первым делом — команда.

### HTTP-запрос внутри tea.Cmd

Правило одно и жёсткое: сеть живёт только в команде. Фабрика — обычная функция, принимающая нужные аргументы и возвращающая готовое замыкание func() tea.Msg: fetchItemsCmd(url) захватывает строку и запускается позже, в горутине фреймворка. Внутри замыкания — обычный Go: http.Get, чтение тела, разбор. Три запрета из пятого урока действуют и здесь: команда не печатает в терминал, не паникует и не трогает модель — она не знает о её существовании. Ошибка любого этапа упаковывается в поле err сообщения-результата: команда вернулась — значит, событие произошло, а каким оно было, решает Update. Хорошая привычка — не отдавать наружу сырые байты: превратите тело в []Item внутри замыкания, тогда Update и View работают с доменными данными и не знают, откуда они приехали. Поменять источник с HTTP на файл или кэш — значит переписать одну фабрику, а не весь Update. Именование фабрик в живых проектах держится одного шаблона: fetchXCmd, loadXCmd, saveXCmd — по префиксу grep находит все сетевые точки программы за секунду, и это отдельный аргумент против инлайн-замыканий посреди Update: их потом не найти.

### Команда загрузки: три точки возврата

```go
type itemsLoadedMsg struct {
        items []Item
        err   error
}

func fetchItemsCmd(url string) tea.Cmd {
        return func() tea.Msg {
                resp, err := http.Get(url)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                defer resp.Body.Close()

                body, err := io.ReadAll(resp.Body)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }

                items, err := parseItems(body)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                return itemsLoadedMsg{items: items}
        }
}
```

Каждый этап может провалиться — запрос, чтение тела, разбор, — и все три провала едут одним сообщением в поле err. fetchItemsCmd возвращает не результат, а замыкание: фреймворк вызовет его в своей горутине. parseItems — ваша функция конвертации байт в доменный срез, Update о HTTP не знает.

### Состояния loading, loaded, error

Данные из сети асинхронны, поэтому модель обязана знать, в какой фазе находится загрузка. Соблазн — три булевых поля loading, loaded, failed — работает плохо: компилятор не мешает комбинации loading и failed одновременно, и рано или поздно она появится. Честнее — один тип-перечисление: stateLoading, stateLoaded, stateError через iota, и модель хранит единственное поле state. Переходы очевидны: нажали r или старт — stateLoading, пришло сообщение с данными — stateLoaded, с ошибкой — stateError. View превращается в switch по состоянию: спиннер или строка «Загружаю...», список, ошибка с подсказкой. Отдельно про экран ошибки: «Ошибка: dial tcp: connection refused» без подсказки — тупик для пользователя, поэтому рядом всегда должна стоять дорога назад — «r — повторить». Ошибка из сети — не крах программы, а штатное состояние, которое она обязана уметь показывать и перезапускать. Полезный расширяемый случай — четвёртое состояние stateRefreshing: данные на экране есть, но поверх идёт обновление, и интерфейс показывает и то, и другое. Не заводите его заранее — на один список хватает трёх состояний, четвёртое появляется вместе с требованием продукта, не раньше.

### Конечный автомат и View по состоянию

```go
type state int

const (
        stateLoading state = iota
        stateLoaded
        stateError
)

func (m listModel) View() string {
        switch m.state {
        case stateLoading:
                return "Загружаю задачи..."
        case stateError:
                return "Ошибка: " + m.err.Error() +
                        "\nr — повторить, q — выход"
        default:
                return fmt.Sprintf("Задач: %d\n", len(m.items))
        }
}
```

Одно поле state вместо трёх булевых: невозможно оказаться в двух состояниях сразу, а switch в View покрывает все ветки. В реальном приложении строку загрузки заменит спиннер из шестого урока — флаг состояния остаётся тем же.

### Проверка: что делать с ошибкой

**Вопрос:** Команда вернулась с itemsLoadedMsg{err: err}. Чего в ней точно быть не должно?

- ✅ Печати в терминал и паники — решение за Update
- Вызова tea.Quit — ошибка всегда завершает программу
- Повторного http.Get внутри сообщения
- Поля err — ошибки надо возвращать вторым значением

Команда не имеет права ни печатать, ни паниковать, ни трогать модель: она упаковывает факт в сообщение. Ошибка — такое же событие, как данные: Update кладёт её в модель, View решает, что показать, пользователь нажимает r и перезапускает загрузку.

### Что на экране до ответа

```go
// state = stateLoaded, items: пусто.
// Пользователь нажал r:

case "r":
        m.state = stateLoading
        return m, fetchItemsCmd(m.url)

// Что покажет View, пока ответ не пришёл?
```

**Что выведет программа?**

- Пустой список — старое состояние
- ✅ «Загружаю...» — состояние сменилось сразу, до ответа
- Ошибку — раз ответа нет, значит провал
- Ничего: View не вызывается во время загрузки

Update меняет state синхронно, а команда вернётся позже — когда сеть ответит. Между этими событиями View рисует stateLoading. View вызывается после каждого сообщения, просто пока ответ не пришёл, других сообщений нет — картинка стоит на «Загружаю...».

### JSON decode внутри команды

Первый сюрприз новичка: http.Get не считает 404 и 500 ошибками. Значение err возвращается только когда запрос не состоялся физически — нет сети, не разрешилось имя, отвалится TLS. Ответ «404 Not Found» — полноценный *http.Response, и если статус не проверить, команда попытается распарсить страницу ошибки как JSON и упадёт с загадочным сообщением. Проверяйте resp.StatusCode руками и упаковывайте resp.Status в err. Второй сюрприз — тело надо закрывать: defer resp.Body.Close() в каждой ветке, где тело вообще открывалось, иначе пул соединений клиента исчерпается. Сам разбор: json.NewDecoder(resp.Body).Decode(&v) читает поток напрямую и не грузит весь ответ в память — для типичных API этого достаточно, а json.Unmarshal уместен, когда байты уже прочитаны другим кодом. Структуру под JSON описывают тегами, и это лучший кандидат на отдельный тип api-слоя, о котором Update не знает. Декодер понимает UTF-8 из коробки, а для недоверенных источников размер ответа стоит ограничить io.LimitReader: читать гигабайт мусора из чужого API не стоит.

### Статус, декодер и конвертация в домен

```go
type apiItem struct {
        ID    int    `json:"id"`
        Title string `json:"title"`
}

func fetchJSONCmd(url string) tea.Cmd {
        return func() tea.Msg {
                resp, err := http.Get(url)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                defer resp.Body.Close()

                if resp.StatusCode != http.StatusOK {
                        return itemsLoadedMsg{err: fmt.Errorf(
                                "сервер вернул %s", resp.Status)}
                }

                var api []apiItem
                if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
                        return itemsLoadedMsg{err: err}
                }

                items := make([]Item, 0, len(api))
                for _, a := range api {
                        items = append(items, Item{ID: a.ID, Title: a.Title})
                }
                return itemsLoadedMsg{items: items}
        }
}
```

Теги json мапят поля ответа на структуру, декодер читает тело напрямую из потока. Проверка StatusCode отсекает 404 и 500 раньше разбора. Последний цикл конвертирует API-тип в доменный Item — модель не узнает, что данные вообще были JSON.

> **Строгий декод для своих API.** json.Decoder умеет требовать совпадения полей: метод DisallowUnknownFields превращает незнакомые ключи ответа в ошибку. Для чужого API это лишнее — лишнее поле завалит весь список, а для своего — страховка от расползания контракта. Включать стоит осознанно, вместе с обсуждением версий API, а не как дефолт.

> **Строгий декод для своих API.** json.Decoder умеет требовать совпадения полей: метод DisallowUnknownFields превращает незнакомые ключи ответа в ошибку. Для чужого API это лишнее — лишнее поле завалит весь список, а для своего — страховка от расползания контракта. Включать стоит осознанно, вместе с обсуждением версий API, а не как дефолт.

### Проверка: чего не хватает

**Вопрос:** http.Get вернул ответ без ошибки, но Decode упал с «invalid character '<'». Что забыли?

- ✅ Проверить resp.StatusCode — сервер отдал HTML со страницей ошибки
- Вызвать resp.Header.Get перед декодированием
- Открыть тело повторно — оно уже прочитано
- Ничего: значит сервер сломан, обработка верна

Классическая картина: запрос «прошёл», но это 500 с HTML-страницей, а не JSON. err от http.Get здесь нулевой — транспорт работал. Сравнение StatusCode с http.StatusOK до декодера превращает загадочный сбой парсера в честное «сервер вернул 500».

### Таймауты и context

Дефолтный http.Get использует глобальный клиент без таймаута: зависший сервер означает зависшую команду — навсегда. Лекарство двухуровневое. Сначала клиент с Timeout: http.Client{Timeout: 5 * time.Second} ограничивает всю жизнь запроса — от набора соединения до чтения тела. Затем контекст: http.NewRequestWithContext принимает context.Context, и контекст с WithTimeout даёт лимит конкретной операции, который жёстче клиентского. Зачем оба? Клиент — страховка от забывчивости, контекст — осознанное решение об операционном лимите, и оба числа озвучиваются в коде явно. В терминальном приложении контекст делает ещё одну вещь: отмену по уходу пользователя — если экран закрыт, а запрос ещё едет, cancel обрывает его и освобождает соединение. Для одного запроса это тонкость, для панели с десятью живыми запросами — гигиена. Про устройство контекстов, отмену и распространение по дереву вызовов подробнее рассказывает [урок про context курса go-advanced](/courses/go-advanced/lessons/09-context/). И практичная деталь: числа таймаутов не универсальны — пять секунд для обычного API нормально, для тяжёлого отчёта издевательство. Лимит передавайте фабрике команды параметром рядом с url, пусть решает вызывающий.

### Клиент с таймаутом и контекст операции

```go
var apiClient = &http.Client{Timeout: 5 * time.Second}

func fetchWithTimeout(url string) tea.Cmd {
        return func() tea.Msg {
                ctx, cancel := context.WithTimeout(
                        context.Background(), 3*time.Second)
                defer cancel()

                req, err := http.NewRequestWithContext(
                        ctx, http.MethodGet, url, nil)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }

                resp, err := apiClient.Do(req)
                if err != nil {
                        return itemsLoadedMsg{err: err}
                }
                defer resp.Body.Close()

                var api []apiItem
                if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
                        return itemsLoadedMsg{err: err}
                }
                return itemsLoadedMsg{items: toItems(api)}
        }
}
```

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

> **Тело без Close — тихая утечка.** resp.Body.Close не вызывает сборщик мусора: без него соединения копятся в пуле клиента. Симптом узнается сразу — первые десяток запросов ходят, дальше всё виснет без ошибок. Один defer сразу после проверки err закрывает вопрос навсегда. Проверьте на это же свои команды: любой http-клиент внутри панели — кандидат.

### Оптимистичный UI и защита от повторов

Оптимистичное обновление — приём, когда изменение применяется до ответа сервера: пользователь пометил задачу выполненной — модель перевернула галочку сразу, а подтверждение приедет позже. Ощущение скорости резкое: интерфейс отвечает со скоростью нажатия, а не сети. Цена — откат: пришла ошибка, и галочку придётся вернуть с честным сообщением. Где оптимизм уместен — на дешёвых обратимых операциях: пометки, лайки, сортировка. Где нет — на создании и удалении: запись, которой нет на сервере, откатывать мучительно для пользователя, поэтому такие действия честнее держать в состоянии loading до ответа. Мое правило — оптимистично только то, что откатывается одним присваиванием. Вторая половина темы — защита от повторов: пользователь жмёт r пять раз подряд, и наивная модель отправит пять параллельных запросов, ответы придут в случайном порядке и затрут друг друга. Флаг уже есть: если state равен stateLoading, ветка r просто игнорирует нажатие. И про порядок ответов: пять параллельных запросов приходят в случайном порядке, и последним может оказаться самый старый — ещё одна причина не плодить запросы, а перезапускать один текущий.

### Запрос при старте: Init

Данные нужны сразу — верните команду из Init, и загрузка стартует вместе с программой: Init() tea.Cmd { return fetchItemsCmd(m.url) }. До прихода ответа модель живёт в stateLoading, и экран не выглядит пустым. Спиннеру нужна собственная команда Tick из шестого урока, а на старте возвращается несколько команд сразу — их объединяет tea.Batch из седьмого: Batch(spinner.Tick, fetchItemsCmd(m.url)) запускает и анимацию, и запрос параллельно. Когда запрос при старте — плохая идея? В утилитах, которые открывают панель ради локальных действий: без сети приложение будет стоять в загрузке без нужды, и данные лучше тянуть по требованию. Отдельный вопрос — кэш: держать последние данные в модели и при перезагрузке показывать их сразу, помечая refresh, — пользователи прокрутки оценят, что позиция не прыгает к началу. Всё это не усложняет механизм: та же команда, то же сообщение, другая точка возврата. Заметьте только, что Init выполняется один раз за жизнь программы, поэтому периодическое обновление списка — не его работа: тикающий refresh делает команда-таймер, о которой дальше по курсу.

### Соберите команду загрузки

Восстановите каркас: запрос, упаковка ошибки, закрытие тела и сборка сообщения.

```go
func fetchItemsCmd(url string) tea.Cmd {
        return func() tea.Msg {
                resp, err := http.___(url)
                if err != nil {
                        return itemsLoadedMsg{___: err}
                }
                defer resp.Body.___()

                items, err := parseItems(resp)
                if err != nil {
                        return itemsLoadedMsg{___: err}
                }
                return itemsLoadedMsg{___: items}
        }
}
```

Подсказка: Запрос делает http.Get, ошибка едет в поле err сообщения, тело закрывает Close, готовый список — в поле items.

Правильные ответы в пропусках: `Get`, `err`, `Close`, `err`, `items`

### Соберите ветку провала загрузки

Расставьте строки: сообщение, проверка ошибки, сохранение, состояние, возврат, закрытие условия.

Правильный порядок строк:

1. `case itemsLoadedMsg:`
2. `  if msg.err != nil {`
3. `    m.err = msg.err`
4. `    m.state = stateError`
5. `    return m, nil`
6. `  }`

### Проверка: где разбирать JSON

**Вопрос:** Почему разбор JSON оставляют команде, а не делают в Update?

- json.NewDecoder не работает вне команд
- ✅ Update обязан возвращаться мгновенно: тяжёлая работа заморозит ввод
- Декодер требует горутину и без неё падает
- Разницы нет — это только вопрос читаемости

Update выполняется в цикле событий: пока он разбирает мегабайт JSON, клавиши копятся в очереди, и интерфейс замирает. Команда живёт в своей горутине — ей и место. В Update данные доезжают уже собранными.

### Карточки: словарь сетевой загрузки

- **Фабрика команды** — Внешняя функция принимает аргументы — url, id — и возвращает замыкание func() tea.Msg. Сеть живёт только внутри замыкания; модель команды не знает.
- **resp.StatusCode** — http.Get возвращает err только на транспортный провал. 404 и 500 — обычный ответ: сравнивайте с http.StatusOK до декодирования.
- **json.NewDecoder** — Декодирует прямо из resp.Body, без чтения в память. Поля мапятся структурой с тегами json; Unmarshal нужен, когда байты уже прочитаны.
- **Timeout и context** — http.Client{Timeout} страхует весь запрос, контекст с WithTimeout ограничивает операцию жёстче и умеет отменять по уходу с экрана.
- **Оптимистичный UI** — Изменение применяется до ответа сервера и откатывается при ошибке. Уместно на обратимых пометках; создание и удаление честнее держать в loading.

### Что дальше

Получать данные научились — следующий шаг отдавать: форма, которая собирает логин и пароль и отправляет их на сервер через ту же команду с http.Post, — это [следующий урок про фокус и формы](/courses/bubbletea/lessons/20-focus-formy/). Пока запрос едет, экран должен жить: спиннер из [шестого урока](/courses/bubbletea/lessons/06-spinner/) вешается на stateLoading за десять минут, а параллельный старт анимации и загрузки — Batch из седьмого. Проверяйте каркас на реальном API — публичные тестовые серверы вроде jsonplaceholder подходят идеально, но только локальный запуск покажет таймауты в действии. Когда загрузок станет много, а фабрики команд размножатся по файлам, им понадобится дисциплина структуры — до неё руки дойдут в [уроке про тестирование TUI](/courses/bubbletea/lessons/22-testirovanie-tui/), где фиктивные ответы проверяют Update без сети. Обзор всего маршрута — на странице [курса Bubble Tea](/courses/bubbletea/).

## Коротко: что запомнить

- Сеть живёт только в tea.Cmd: Update отвечает за состояние, команда — за работу.
- Сообщение-результат несёт данные и err; три состояния loading, loaded, error рисуются switch в View.
- http.Get не ошибается на 404 и 500 — проверяйте resp.StatusCode руками до декодирования.
- json.NewDecoder(resp.Body) разбирает поток; поля мапятся структурой с тегами json.
- Клиенту нужен Timeout, операции — context.WithTimeout; resp.Body закрывается defer всегда.
- Оптимистичный UI — только для обратимых пометок; флаг stateLoading защищает от повторных запросов.

## Домашнее задание

Заведите команду загрузки для публичного тестового API вроде jsonplaceholder.typicode.com/todos?_limit=10. Выведите список через три состояния, добавьте ветку ошибки с подсказкой r, таймаут клиента в пять секунд и игнорирование r во время загрузки. Проверьте оба сценария: с сетью и с выключенным Wi-Fi — во втором случае экран ошибки и повторная загрузка обязаны сработать.

## Вопросы и ответы по уроку

### Команда зависла навсегда — что происходит?

Скорее всего http.Get без таймаута: клиент по умолчанию ждёт ответа бесконечно, Update жив, но данные не придут никогда. Дайте клиенту Timeout и накройте запрос context.WithTimeout — тогда провал вернётся сообщением, и интерфейс покажет ошибку вместо вечной загрузки.

### Куда девать статус ответа, например 404?

В сообщение. Нестандартный ответ — тоже результат: сравните resp.StatusCode с http.StatusOK и упакуйте resp.Status в err поля сообщения. View покажет «сервер вернул 404 Not Found», и пользователь поймёт, что ссылка битая, а не сеть легла.

### Нужно ли отменять запрос, когда пользователь ушёл с экрана?

Для одного запроса — нет: сообщение просто обновит скрытую модель. Отмена нужна, когда запросов много и они тяжёлые: храните CancelFunc рядом с моделью и вызывайте при уходе. Устройство отмены — контексты из курса go-advanced, там это разобрано от и до.
