---
title: "Проксирование и стриминг ответов"
description: "Проксирование в gin: httputil.ReverseProxy с Rewrite, round-robin балансировка, c.Stream и SSE-потоки, hop-by-hop заголовки и ErrorHandler против пустого 502."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/19-proksi-streaming/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Проксирование и стриминг ответов

*Курс «Gin: самый популярный веб-фреймворк Go», урок 19 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/19-proksi-streaming/*

Пора пропустить чужие ответы через себя: фронтенду нужен единый адрес с одним TLS-сертификатом, бэкендам — независимые релизы, а вам — место, где [звенья из урока про middleware](/courses/gin/lessons/06-middleware/) стоят перед чужим трафиком. Обратный прокси на gin — это обычный хендлер в [группе маршрутов из урока 10](/courses/gin/lessons/10-gruppy/), только вместо JSON он перекладывает байты: запрос уходит на бэкенд, ответ течёт обратно без единого лишнего буфера в памяти. Разберём gin прокси по-честному: httputil.ReverseProxy с современным Rewrite и легаси-Director, балансировку round-robin на атомарном счётчике, хуки ModifyResponse и ErrorHandler. Затем стриминг gin: io.Copy без накопления в памяти, c.Stream с шаговой функцией и честный SSE — text/event-stream, data:, event:, переподключение по Last-Event-ID. И грабли: буферизация на промежуточных слоях, WriteTimeout, режущий длинные потоки, и 502 без конверта, если забыть ErrorHandler. Заодно вспомним, как [лимиты из урока 18](/courses/gin/lessons/18-rate-limit/) встают перед прокси и защищают бэкенды одним звеном.

### gin прокси: зачем фреймворку чужие ответы

Обратный прокси — сервер, который принимает запрос, сам ходит за ответом к бэкенду и возвращает его клиенту как свой. Зачем это нужно, когда есть nginx? Во-первых, единая дверь: одно доменное имя, один TLS, а за ним хоть десять сервисов — фронтенд не хочет знать их адреса и CORS-танцы исчезают сами собой, как мы обсуждали в уроке про CORS. Во-вторых, место для логики: у nginx язык конфигов, а у вас — Go. Выбор бэкенда по клейму из JWT, тарифу пользователя или весовой доле — это код, а не config. В-третьих, агрегация: типичный BFF (backend for frontend) собирает данные с нескольких сервисов, и проксирование — его базовый приём. gin прокси здесь удобен тем, что это обычный хендлер: вокруг него работают те же группы, звенья, конверт ошибок и логирование, что и вокруг остального приложения. Важное уточнение по ресурсам: прокси не читает ответы целиком — он копирует поток из сети в сеть, поэтому бэкенд может отдать гигабайтный файл, а память прокси останется на уровне буфера в десятки килобайт. Как это устроено — тема второй половины урока.

### httputil.ReverseProxy: Director и Rewrite

Основа — httputil.ReverseProxy из стандартной библиотеки: структура с несколькими точками вмешательства. Transport — чем ходить на бэкенд (по умолчанию http.DefaultTransport); ModifyResponse — правка ответа до отправки клиенту; ErrorHandler — что ответить, когда бэкенд недоступен; FlushInterval — как часто выталкивать буферы клиенту; ErrorLog — куда жаловаться. Главный выбор — функция-маршрутизатор запроса, и тут два поколения API. Легаси-путь — Director func(*http.Request): вам дают исходящий запрос, вы правите URL.Scheme, URL.Host, URL.Path — как хотите. Современный путь, с Go 1.20, — Rewrite func(*httputil.ProxyRequest): в структуре лежат In (запрос клиента, только чтение) и Out (запрос к бэкенду), плюс два готовых метода: SetURL(target) проставляет схему, хост и путь цели с сохранением query, а SetXForwardedFor() заполняет заголовки X-Forwarded-For, X-Forwarded-Host и X-Forwarded-Proto. Разница глубже синтаксиса: перед Rewrite прокси стирает клиентские X-Forwarded-* — защита от подделки, а перед Director не стирает; и после Director прокси сам дописывает IP клиента к существующему X-Forwarded-For, а в режиме Rewrite ничего не ставит без вашего ведома. Шорткат NewSingleHostReverseProxy(target) собирает Director за вас: константа цели, склейка путей, сохранение query — идеально для первого знакомства.

### Минимальный прокси: NewSingleHostReverseProxy

```go
package main

import (
        "net/http"
        "net/http/httputil"
        "net/url"

        "github.com/gin-gonic/gin"
)

func main() {
        target, _ := url.Parse("http://127.0.0.1:9000")

        proxy := httputil.NewSingleHostReverseProxy(target)

        r := gin.New()
        r.Use(gin.Logger(), gin.Recovery())

        // Всё с /api уходит на бэкенд, префикс отрезаем.
        r.Any("/api/*path", func(c *gin.Context) {
                c.Request.URL.Path = c.Param("path")
                proxy.ServeHTTP(c.Writer, c.Request)
        })

        r.Run(":8080")
}
```

NewSingleHostReverseProxy вернул структуру с готовым Director-ом: цель константа, пути склеиваются. В хендлере мы подменяем путь запроса: wildcard ловит /api/todos, а бэкенд ждёт /todos — c.Param("path") отдаёт хвост без префикса. Метод Any прокидывает все HTTP-глаголы сразу. ServeHTTP принимает стандартные ResponseWriter и Request — gin-писатель реализует первый, поэтому прокси встаёт в хендлер без обёрток.

### Проверка: судьба X-Forwarded-For

**Вопрос:** Клиент шлёт X-Forwarded-For: 203.0.113.7 и попадает на NewSingleHostReverseProxy. Что окажется в этом заголовке на бэкенде?

- Ничего: прокси никогда не трогает X-Forwarded-For
- ✅ Адрес клиента допишется в конец списка: в режиме Director прокси сохраняет чужие значения и добавляет свой
- Значение заменится адресом клиента: прокси всегда стирает список и пишет только свой IP
- Заголовок исчезнет: hop-by-hop заголовки не переживают прокси

В Director-режиме ReverseProxy после вызова Director дописывает IP клиента к существующему X-Forwarded-For — так строится цепочка прокси-хопов. Стирание и чистый лист — поведение режима Rewrite: перед ним X-Forwarded-* удаляются как недоверенные, а SetXForwardedFor заполняет заново. Неслучайно c.ClientIP() из урока про лимиты доверяет этим заголовкам только от прописанных доверенных прокси.

### Прокси на группе: round-robin и выбор цели

Один бэкенд — разминочный круг. Реальный шлюз выбирает цель: реплики крутятся по кругу (round-robin), тарифный план решает, кому достанется тяжёлый запрос, апи-версия в заголовке развозит по версиям сервиса. Механика та же, но прокси теперь не одна, а массив: создаём пул один раз при старте — структуры дешёвые, а Director/Rewrite-замыкание хранит свою цель. Счётчик — atomic.Uint64: Add(1) атомарен, горутины не дерутся, а index по модулю длины пула даёт честный круг. Хендлер на группе из урока 10 принимает /v1/*path, отрезает префикс и передаёт выбранному прокси. Если нужен префикс на бэкенде — не отрезайте, SetURL склеит путь цели с хвостом. Для sticky-сессий (клиент всегда к той же реплике) ключом берут cookie из урока про сессии или hash идентификатора — round-robin ломает липкость, но для stateless-API это неважно. И помните: у каждого прокси из пула свои ErrorHandler и ModifyResponse — собирайте их фабрикой, чтобы не копипастить.

### Пул прокси с Rewrite: round-robin по репликам

```go
targets := []*url.URL{
        {Scheme: "http", Host: "127.0.0.1:9001"},
        {Scheme: "http", Host: "127.0.0.1:9002"},
        {Scheme: "http", Host: "127.0.0.1:9003"},
}

var next atomic.Uint64
proxies := make([]*httputil.ReverseProxy, len(targets))
for i, t := range targets {
        t := t // захват переменной цикла — своя цель на прокси
        proxies[i] = &httputil.ReverseProxy{
                Rewrite: func(pr *httputil.ProxyRequest) {
                        pr.SetURL(t)          // схема, хост, склейка пути
                        pr.SetXForwardedFor() // XFF/XFH/XFP заново
                },
                ModifyResponse: func(resp *http.Response) error {
                        resp.Header.Set("X-Upstream", t.Host)
                        return nil
                },
                ErrorHandler: func(w http.ResponseWriter,
                        r *http.Request, err error) {
                        w.WriteHeader(http.StatusBadGateway)
                },
        }
}

r.Any("/v1/*path", func(c *gin.Context) {
        idx := int(next.Add(1)-1) % len(proxies)
        c.Request.URL.Path = c.Param("path")
        proxies[idx].ServeHTTP(c.Writer, c.Request)
})
```

Пул собирается на старте: три структуры, у каждой своя цель в замыкании. Rewrite — современный режим: SetURL ставит цель и склеивает путь с сохранением query, SetXForwardedFor затирает клиентские X-Forwarded-* и заполняет честными. ModifyResponse ставит отладочный X-Upstream, ErrorHandler отдаёт 502 — в бою тут будет JSON-конверт из урока про ошибки. Хендлер — три строки: выбрал реплику атомарным счётчиком, отрезал префикс, отдал прокси.

### Проверка: куда уйдут пять запросов

**Вопрос:** Пул из двух реплик: 9001 и 9002. next — atomic.Uint64 с нуля; хендлер берёт idx = int(next.Add(1)-1) % len(proxies). Пять запросов подряд — куда они уйдут?

- ✅ 9001, 9002, 9001, 9002, 9001 — счётчик с единицы, круг честный
- 9002, 9001, 9002, 9001, 9002 — Add возвращает новое значение, но вычитание единицы сдвигает старт
- 9001, 9001, 9002, 9002, 9001 — гонка горутин портит порядок
- 9002, 9002, 9001, 9001, 9002 — модуль от нуля ломает первый индекс

Add(1) возвращает новое значение: 1, 2, 3, 4, 5. Вычитаем единицу — индексы 0, 1, 0, 1, 0, модуль по двум репликам даёт 9001, 9002, 9001, 9002, 9001. Без вычитания единицы первый запрос ушёл бы на 9002 — классическая off-by-one на atomic-счётчиках. Гонки нет: Add атомарен, порядок запросов среди горутин может меняться, но каждому достанется свой индекс.

### Поток без памяти: io.Copy и FlushInterval

Почему прокси не боится гигабайтных ответов? Потому что он не читает их в память. ReverseProxy копирует тело через io.Copy: буфер 32 КиБ, прочитали — записали клиенту, забыли. Ручной хендлер повторяет ту же логику в три строки: http.Get (или клиент с таймаутами), скопировать заголовки, io.Copy из resp.Body в c.Writer. Буферизация в памяти нужна только если вы реально хотите править тело — тогда вы платите RAM-ом за размер ответа и осознанно, а не случайно. Вторая сторона — своевременность. Байты, записанные в ResponseWriter, по умолчанию не обязаны покидать процесс немедленно: сетевой слой собирает их в пакеты. Для стриминга нужен Flush — явное «выталкивай сейчас». gin-писатель реализует http.Flusher, поэтому c.Writer.Flush() доступен всегда. У ReverseProxy за это отвечает FlushInterval: с Go 1.16 он сам ставит мгновенный flush для ответов с Content-Type text/event-stream и без известной Content-Length — стриминг работает из коробки; для обычных ответов прокси не торопится, и если бэкенд шлёт данные кусками, задайте FlushInterval: 100 * time.Millisecond. Отдача файлов с диска — та же идея без сети: c.File из урока про загрузку файлов льёт их стандартным FileServer-ом.

### Ручной потоковый хендлер: строка за строкой

```go
r.GET("/logs", func(c *gin.Context) {
        upstream, err := http.Get("http://127.0.0.1:9000/logs.txt")
        if err != nil {
                c.JSON(http.StatusBadGateway, gin.H{
                        "error": "upstream недоступен"})
                return
        }
        defer upstream.Body.Close()

        c.Writer.Header().Set("Content-Type",
                "text/plain; charset=utf-8")
        c.Writer.WriteHeader(upstream.StatusCode)

        sc := bufio.NewScanner(upstream.Body)
        for sc.Scan() {
                fmt.Fprintln(c.Writer, sc.Text())
                c.Writer.Flush() // строка уходит немедленно
        }
})
```

Статус и Content-Type берём с бэкенда — клиент узнаёт правду об апстриме. Scanner читает строку, Fprintln пишет её клиенту, Flush выталкивает — хвост лога течёт в браузер в реальном времени, память прокси при этом нулевая. Помните потолок Scanner: строка длиннее 64 КиБ уронит скан — для произвольных данных берите io.Copy с FlushInterval, для строк — bufio.Reader.ReadString.

### c.Stream: шаговая функция gin

Для кусочных ответов у gin есть готовый метод: c.Stream(step func(w io.Writer) bool) bool. Контракт простой: gin крутит цикл, на каждом обороте зовёт вашу step, а после каждого вызова сам делает Flush — выталкивание встроено, забывать не надо. step пишет в writer очередной кусок и возвращает решение: true — продолжаем, false — поток закончен. Выход из цикла происходит и без вашего участия: gin слушает CloseNotify соединения и прекращает цикл, когда клиент ушёл — сочинять свой детектор отключения не нужно. Внутри step обычно стоит select: данные из канала, тикер времени, или Context.Done() запроса — контекст отменяется при разрыве, и это второй способ узнать об уходе клиента. Шаговая модель закрывает три кейса: деградированный long-polling, генерация данных на лету (курс валют раз в секунду) и — главный кейс — SSE, до которого дойдём через блок. Против бесконечного цикла с ручным Flush плюс минус очевидны: c.Stream короче, не забывает Flush и корректно закрывается, а вы остаётесь с одной строчкой логики на шаг.

### Курс валют шагом: c.Stream и тикер

```go
r.GET("/rate", func(c *gin.Context) {
        c.Writer.Header().Set("Content-Type", "text/plain")

        c.Stream(func(w io.Writer) bool {
                select {
                case <-time.After(5 * time.Second):
                        rate := fetchRate() // ход на бэкенд
                        fmt.Fprintf(w, "курс: %s\n", rate)
                        return true // следующий шаг через 5 секунд
                case <-c.Request.Context().Done():
                        return false // клиент отключился
                }
        })
})
```

Каждый шаг ждёт тикера, пишет строку и возвращает true — c.Stream сама делает Flush после шага, клиент получает курс каждые пять секунд. Context.Done закрывает поток при разрыве соединения. time.After внутри цикла аллоцирует таймер на шаг; для долгоживущих потоков экономнее time.NewTicker с defer ticker.Stop.

### Что увидит клиент при первом false

```go
c.Stream(func(w io.Writer) bool {
        fmt.Fprint(w, "раз ")
        return false
})
```

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

- Ничего: последний шаг не выталкивается — Flush делается только перед true
- Бесконечный поток «раз раз раз…»: false игнорируется, если писали данные
- ✅ Строку «раз » и конец ответа: Flush после шага отдал байты, false закрыл цикл
- Панику: step обязан вернуть true хотя бы один раз

Порядок в c.Stream: step пишет, затем gin зовёт Flush, затем проверяет возвращаемое значение — поэтому даже последний кусок доходит до клиента. false просто прекращает цикл, соединение завершается штатно с полным телом. Такой одношаговый поток — легальный способ отдать сгенерированный на лету ответ без ручного Flush.

### SSE: протокол событий текстом

Server-Sent Events — стандарт односторонних потоков от сервера к браузеру поверх обычного HTTP. Контент-тип text/event-stream, формат — текст: строка data: несёт полезную нагрузку, event: задаёт имя события, id: — идентификатор для восстановления, retry: — интервал переподключения в миллисекундах; пустая строка означает «событие собрано, отправляй». Два data: подряд склеиваются переносом — многострочные сообщения соберутся сами. На клиенте EventSource: конструктор с URL, обработчики onmessage и addEventListener по имени события — браузер сам переподключается при обрыве, сам шлёт Last-Event-ID с последним полученным id, и сервер может дозировать пропущенное. Всё это живёт на обычном HTTP: прокси, балансировщики и gzip-уровни видят стандартный ответ, а не экзотический протокол. В gin SSE собирается из уже знакомых деталей: заголовки text/event-stream и Cache-Control: no-cache, канал с событиями, c.Stream с шагом, а c.SSEvent(event, obj) пишет форматированное событие целиком — имя, data и перевод строк по стандарту. Двусторонний обмен и бинарные данные — территория WebSocket из урока 20-websocket: там апгрейд соединения и gorilla/websocket, здесь — чистый HTTP, который проходит через любой прокси.

### SSE-хаб: каналы и broadcast

```go
type hub struct {
        mu      sync.Mutex
        clients map[chan string]struct{}
}

func (h *hub) add() chan string {
        ch := make(chan string, 16)
        h.mu.Lock()
        h.clients[ch] = struct{}{}
        h.mu.Unlock()
        return ch
}

func (h *hub) remove(ch chan string) {
        h.mu.Lock()
        delete(h.clients, ch)
        h.mu.Unlock()
}

func (h *hub) broadcast(msg string) {
        h.mu.Lock()
        defer h.mu.Unlock()
        for ch := range h.clients {
                select {
                case ch <- msg:
                default: // буфер полон — медленный клиент пропущен
                }
        }
}

r.GET("/events", func(c *gin.Context) {
        ch := hub.add()
        defer hub.remove(ch)

        c.Writer.Header().Set("Content-Type", "text/event-stream")
        c.Writer.Header().Set("Cache-Control", "no-cache")

        c.Stream(func(w io.Writer) bool {
                select {
                case msg := <-ch:
                        c.SSEvent("message", msg)
                        return true
                case <-c.Request.Context().Done():
                        return false
                }
        })
})

r.POST("/push", func(c *gin.Context) {
        var body struct {
                Text string `json:"text"`
        }
        if err := c.ShouldBindJSON(&body); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }
        hub.broadcast(body.Text)
        c.Status(http.StatusAccepted)
})
```

Хаб — карта каналов под мьютексом: add при подключении, remove через defer при любом выходе из хендлера, broadcast раскладывает сообщение всем. default в broadcast — защита от медленного подписчика: переполненный буфер лучше пропустить, чем заморозить всех. Эндпоинт /events ставит заголовки SSE и живёт в c.Stream до разрыва. POST /push публикует событие из любого клиента — bind и конверт ошибок стандартные.

### Событие на проводе

```go
c.SSEvent("price", "100")
```

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

- ✅ event: price\ndata: 100\n\n — имя, данные и пустая строка-терминатор
- data: {"event":"price","data":"100"}\n\n — один JSON в data
- price: 100\n\n — имя события в роли заголовка
- {"event":"price","data":"100"}\n\n — сырой JSON без префиксов

c.SSEvent оборачивает значение в sse.Event и пишет по стандарту: строка event: с именем, строка data: с полезной нагрузкой, пустая строка — конец события. Строковые данные идут как есть, нестроковые — через json.Marshal. Именно пустую строку ждёт EventSource, чтобы поднять обработчик addEventListener("price").

### Заголовки по пути: hop-by-hop и X-Forwarded-*

Прокси не пересылает всё подряд. Стандарт делит заголовки на end-to-end и hop-by-hop: вторые описывают связь между соседними узлами и на следующем хопе бессмысленны — ReverseProxy вырезает Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade и Proxy-* до похода на бэкенд. Именно поэтому заголовок Upgrade из SSE не ломает проксирование, а WebSocket через ReverseProxy без специальных манипуляций не пройдёт — для апгрейда нужен туннель, о нём поговорим в уроке про WebSocket. Вторая группа — навигационные X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto: бэкенду нужно знать настоящий адрес клиента, оригинальный хост и схему. Здесь действует правило из урока про лимиты: верить им можно только от доверенных прокси — иначе первый пришедший напишет себе любой X-Forwarded-For. Режим Rewrite решает это радикально: стирает клиентские X-Forwarded-* перед Rewrite-функцией, и SetXForwardedFor заполняет их заново от реального RemoteAddr. Если ваш gin-шлюз сам стоит за nginx, не забудьте про SetTrustedProxies — иначе ClientIP на шлюзе покажет nginx, и per-IP лимиты из урока 18 слипнутся в одно ведро. Host бэкенду проставляет SetURL; если нужно сохранить оригинальный — ведро в Out.Host остаётся пустым, а X-Forwarded-Host унесёт правду.

### Точки вмешательства ReverseProxy

| Точка | Когда вызывается | Что там делать |
| --- | --- | --- |
| Rewrite (современный) | до похода на бэкенд, после вырезания hop-by-hop и X-Forwarded-* | SetURL к цели, SetXForwardedFor, правка пути и заголовков запроса |
| Director (легаси) | до похода на бэкенд, X-Forwarded-* не стираются | правка URL.Request цели; для нового кода предпочитайте Rewrite |
| ModifyResponse | получен ответ от бэкенда, до отправки клиенту | добавить/убрать заголовки, подменить статус, посчитать метрику |
| ErrorHandler | ошибка похода на бэкенд: таймаут, refusal, сбой транспорта | конверт ошибок клиенту, метрика сбоя, аккуратный 502 |
| FlushInterval | во время копирования тела | периодический flush для стриминга; text/event-stream и ответы без длины флешатся сами |

### Проверка: бэкенд умер

**Вопрос:** Реплика за прокси перезапускается, соединение отваливается по ошибке транспорта. ErrorHandler не задан. Что получит клиент?

- JSON-конверт с кодом UPSTREAM_FAILED: gin сам превращает ошибки прокси в ваши структуры ошибок
- 200 с пустым телом: прокси молча проглотит отказ и завершит ответ
- ✅ 502 Bad Gateway с пустым телом: дефолтный ErrorHandler пишет причину только в ErrorLog
- Панику: Recovery ответит 500 с текстом ошибки транспорта

Без своего ErrorHandler ReverseProxy отвечает 502 с пустым телом, а детали кладёт в ErrorLog — клиент не получает ни кода, ни подсказки. Это ломает договорённость об едином конверте ошибок: ErrorHandler — место, где ставится AbortWithStatusJSON с кодом из вашего словаря. Паники нет: ошибка транспорта возвращается значением, а не паникой, до Recovery дело не доходит.

### Грабли: буферизация, таймауты и медленные клиенты

Первый грабль — невидимая буферизация на пути. Ваш SSE-эндпоинт честно шлёт данные раз в секунду, а браузер молчит: виноват не gin, а промежуточный слой. Собственный gzip-звено на группе собирает тело в буфер — стриминговым маршрутам нужен обход; nginx по умолчанию буферизует проксированные ответы — лечится X-Accel-Buffering: no в ответе бэкенда или proxy_buffering off в конфиге. Диагностика проста: curl видит поток, браузер — нет, значит буферизует кто-то посередине. Второй грабль — таймауты http.Server: WriteTimeout, поставленный на весь сервер, обрежет любой поток длиннее его значения — соединение молча закроется через N секунд. Для серверов со стримингом WriteTimeout обнуляют и контролируют дедлайны точечно через контекст запроса. Третий — таймауты бэкенда: дефолтный Transport ждёт вечно, зависшая реплика повесит и хендлер прокси; настраивайте ResponseHeaderTimeout и общий дедлайн на клиенте. Четвёртый — медленные подписчики SSE: broadcast с default-веткой теряет события для переполненного канала, но сохраняет остальных; очереди без границ в памяти — путь к OOM. Пятый — дублирование лимитов: лимит запросов gin на шлюзе защищает бэкенды бесплатно, не забудьте поставить звено из [урока 18](/courses/gin/lessons/18-rate-limit/) перед прокси-группой, а конверт ошибок взять из [урока 17-oshibki](/courses/gin/lessons/17-oshibki/).

### Шесть правил прокси и стриминга

- Пул прокси собирается один раз на старте: цель живёт в замыкании Rewrite, хендлер только выбирает индекс и отрезает префикс.
- Rewrite вместо Director для нового кода: SetURL и SetXForwardedFor, стирание клиентских X-Forwarded-* — встроенная защита от подделки.
- Прокси не копит тело: io.Copy кусками по 32 КиБ; буферизация в памяти — осознанное решение за RAM, а не дефолт.
- c.Stream сама делает Flush после каждого шага и ловит уход клиента через CloseNotify; step вернул false — поток закончен штатно.
- SSE — text/event-stream, no-cache, c.SSEvent на канал; переподключение и Last-Event-ID — забота EventSource, а не вашего кода.
- Буферизация посередине и WriteTimeout режут потоки тихо: обходите gzip на стриминговых группах, X-Accel-Buffering: no, дедлайны через контекст, ErrorHandler — для честного 502-конверта.

### Соберите SSE-эндпоинт

Допишите поток событий: контент-тип, вход в c.Stream, публикацию события и реакцию на разрыв.

```go
r.GET("/events", func(c *gin.Context) {
        ch := hub.add()
        defer hub.remove(ch)

        c.Writer.Header().Set("Content-Type", "text/___")
        c.Writer.Header().Set("Cache-Control", "no-cache")

        c.___(func(w io.Writer) bool {
                select {
                case msg := <-ch:
                        c.___("message", msg)
                        return true // поток продолжается
                case <-c.Request.Context().___():
                        return false // клиент ушёл
                }
        })
})
```

Подсказка: Контент-тип по стандарту SSE — text/event-stream; метод с шаговой функцией — c.Stream; форматированное событие пишет c.SSEvent; канал контекста закрывается методом Done.

Правильные ответы в пропусках: `event-stream`, `Stream`, `SSEvent`, `Done`

### Соберите жизнь одного SSE-сообщения

Клиент смотрит ленту событий. Разложите путь сообщения: от подписки до тихого разрыва.

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

1. `curl открывает GET /events и держит соединение открытым`
2. `Хендлер зовёт hub.add(): канал кладётся в карту под мьютексом`
3. `defer hub.remove(ch) встаёт на выход из хендлера`
4. `Ставим Content-Type: text/event-stream и Cache-Control: no-cache`
5. `c.Stream входит в цикл: select ждёт канал или Context.Done`
6. `Другой клиент бьёт POST /push — broadcast кладёт строку в буфер канала`
7. `step просыпается: c.SSEvent пишет event: message и data: с текстом`
8. `c.Stream вызывает Flush — байты уходят в curl немедленно`
9. `curl печатает событие и продолжает ждать следующего`
10. `Пользователь жмёт Ctrl-C: соединение рвётся, Context.Done закрывается`
11. `step возвращает false — цикл c.Stream завершён`
12. `defer удаляет канал из хаба: broadcast больше не учитывает мёртвого клиента`

### Карточки: прокси и стриминг

- **httputil.ReverseProxy** — Структура-прокси из стандартной библиотеки: копирует запрос на бэкенд и тело ответа обратно через io.Copy кусками 32 КиБ — память не зависит от размера ответа. Точки вмешательства: Rewrite/Director, ModifyResponse, ErrorHandler, FlushInterval, ErrorLog.
- **Rewrite против Director** — Rewrite (Go 1.20+) получает ProxyRequest{In, Out}: SetURL ставит цель, SetXForwardedFor заполняет XFF/XFH/XFP; клиентские X-Forwarded-* стираются до вызова — анти-спуфинг. Director — легаси: правка *http.Request, XFF не стираются, IP клиента дописывается автоматически. Новому коду — Rewrite.
- **c.Stream** — c.Stream(step func(w io.Writer) bool): цикл с вызовом step, авто-Flush после каждого шага, выход по false или по уходу клиента (CloseNotify). Пошагово отдаём курс валют, деградированный long-polling или SSE; разрыв ловим ещё и через c.Request.Context().Done().
- **Формат SSE** — Content-Type: text/event-stream; строки data:, event:, id:, retry:; пустая строка завершает событие; два data: склеиваются переносом. Клиент — EventSource: сам переподключается, шлёт Last-Event-ID, события по addEventListener. c.SSEvent пишет формат целиком.
- **Заголовки через прокси** — Hop-by-hop (Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, Proxy-*) вырезаются; end-to-end идут дальше. X-Forwarded-* в Rewrite-режиме стираются и заполняются через SetXForwardedFor; доверять им можно только от SetTrustedProxies — иначе ClientIP покажет соседний прокси.
- **Кто буферизует поток** — Gzip-звенья на группе, nginx (proxy_buffering), WriteTimeout сервера — три тихих убийцы стриминга. Лечение: обход gzip на стриминговых маршрутах, X-Accel-Buffering: no, WriteTimeout 0 с дедлайнами через контекст. ErrorHandler даёт честный 502-конверт вместо пустого тела.

> **Прогоните прокси двумя терминалами.** Поднимите бэкенд с /logs.txt и /events на :9000, шлюз из урока на :8080 — и гоняйте curl: curl localhost:8080/api/logs покажет чужой лог как свой ответ; curl -N localhost:8080/events в одном терминале и curl -X POST localhost:8080/push -d '{"text":"привет"}' в другом — событие приедет мгновенно, флаг -N отключает буферизацию самого curl; убейте бэкенд — получите пустой 502, допишите ErrorHandler и увидите свой конверт. Проверьте заголовки: curl -i покажет X-Upstream от ModifyResponse и стёртые X-Forwarded-For; поставьте лимит-звено из урока 18 перед группой /v1 и убедитесь, что оно режет трафик до прокси. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 20-websocket: gorilla/websocket, апгрейд соединения и двусторонний чат — SSE умеет только шептать в одну сторону, а WebSocket разговорит обе.

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

- Обратный прокси на gin — обычный хендлер: httputil.ReverseProxy перекладывает запрос и ответ, память не растёт от размера тела, потому что копирование идёт потоками по 32 КиБ.
- Rewrite — современная точка маршрутизации: SetURL к цели, SetXForwardedFor с автоматическим стиранием клиентских X-Forwarded-*; Director — легаси с дописыванием IP к существующему списку.
- Пул реплик — массив прокси, собранный на старте, и атомарный счётчик с модулем: round-robin в три строки хендлера, ModifyResponse и ErrorHandler — свои у каждого прокси.
- Стриминг gin держится на Flush: gin-писатель реализует http.Flusher, c.Stream делает Flush после каждого шага и ловит уход клиента, FlushInterval заведует своевременностью у ReverseProxy.
- SSE — обычный HTTP с text/event-stream: data:, event:, id:, retry:, пустая строка-терминатор; EventSource сам переподключается и шлёт Last-Event-ID, серверу остаётся хаб каналов и c.SSEvent.
- Тихие убийцы потоков — буферизация посередине и WriteTimeout: обход gzip, X-Accel-Buffering: no, дедлайны через контекст, ErrorHandler с конвертом вместо пустого 502.

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

Соберите шлюз из трёх реплик на :9001-:9003. Пул httputil.ReverseProxy с Rewrite: SetURL, SetXForwardedFor, X-Upstream в ModifyResponse и ErrorHandler с конвертом ошибок UPSTREAM_UNAVAILABLE и 502. Группа /v1 с round-robin на atomic.Uint64 и отрезанием префикса. Рядом SSE-эндпоинт /events: хаб с каналами под мьютексом, буфер 16, broadcast с default-веткой, заголовки text/event-stream и no-cache, c.Stream с c.SSEvent и Context.Done. POST /push публикует через bind. Проверьте: пять запросов уходят строго по кругу 9001-9002-9003; curl -N /events показывает событие мгновенно после /push; Ctrl-C в curl убирает канал из хаба; убитая реплика отдаёт ваш JSON-конверт, а не пустой 502. Убедитесь, что gzip-звено, если появилось, обходит группу /events, и что WriteTimeout у сервера либо нулевой, либо больше максимального времени жизни потока.

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

### Rewrite или Director для нового кода?

Rewrite, если минимальная версия Go позволяет: он получает ProxyRequest с разделением In/Out, SetURL и SetXForwardedFor делают типовые вещи, а клиентские X-Forwarded-* стираются автоматически — спуфинг заголовков от клиента отсечён на уровне библиотеки. Director остаётся для совместимости и тонких случаев, где нужна правка именно *http.Request, но для него придётся вручную думать о доверии к XFF.

### Чем c.Stream лучше ручного цикла с Flush?

Тремя вещами: Flush после каждого шага встроен и о нём нельзя забыть; выход по уходу клиента уже реализован через CloseNotify; контракт step true/false делает код читаемым. Ручной цикл нужен, когда шаг и выталкивание должны разойтись — например, сканирование большого тела с Flush раз в мегабайт.

### Почему браузер не видит SSE-поток, а curl видит?

Классика буферизации посередине: gzip-звено на группе собирает тело, nginx буферизует проксированный ответ, антивирус на клиенте любит перемешивать потоки. Снимите gzip со стриминговой группы, поставьте X-Accel-Buffering: no или proxy_buffering off — и проверьте заголовок Content-Type: браузер требует точного text/event-stream, с charset-суффиксом EventSource может не стартовать.

### SSE или WebSocket для чата?

Односторонний поток сервера — SSE: HTTP, автопереподключение, Last-Event-ID, ноль зависимостей. Двусторонний обмен, бинарные кадры, частые сообщения от клиента — WebSocket: апгрейд соединения, gorilla/websocket и отдельный разговор про прокси, потому что Upgrade-заголовок hop-by-hop и через ReverseProxy не пролезает без настройки. Лента уведомлений — SSE; чат, игра, совместное редактирование — WebSocket из следующего урока.
