17. HTTP и сетевое взаимодействие

«Хорошо в стандартах то, что их так много на выбор».
— Эндрю Таненбаум


Эволюция сетевых протоколов: HTTP/1.1 → HTTP/2 → HTTP/3 (QUIC)

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    H1["HTTP/1.1 (TCP)"]:::warning -->|"Head-of-Line Blocking на уровне HTTP"| H2["HTTP/2 (TCP + TLS 1.2/1.3)"]:::accent
    H2 -->|"Head-of-Line Blocking на уровне TCP при потере пакета"| H3["HTTP/3 (QUIC поверх UDP + TLS 1.3)"]:::success

    subgraph H1Features["Особенности HTTP/1.1"]
        F1["Текстовый формат, Keep-Alive, 1 запрос на TCP сокет одновременно"]:::process
    end

    subgraph H2Features["Особенности HTTP/2"]
        F2["Бинарный фрейминг, мультиплексирование потоков в 1 TCP сокет, HPACK"]:::process
    end

    subgraph H3Features["Особенности HTTP/3"]
        F3["Независимые потоки в UDP (нет TCP HOL), 0-RTT рукопожатие, Connection ID миграция"]:::success
    end
  

Архитектура HTTP-сервера в Go (net/http)

Go реализует модель «горутина на каждое TCP-соединение» поверх неблокирующего netpoller ядра ОС:

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    Listen["net.Listen('tcp', ':8080') -> сокет листенера"]:::entry --> AcceptLoop["Бесконечный цикл: l.Accept()"]:::process
    AcceptLoop --> Fork["go c.serve(connCtx) (Новая горутина на каждый TCP-сокет!)"]:::accent
    Fork --> ReadReq["Чтение заголовков и URL (bufio.Reader)"]:::process
    ReadReq --> Match["Роутер http.ServeMux (Go 1.22+ method + path)"]:::process
    Match --> Middleware["Middleware цепочка (Logging, Auth, Recovery)"]:::accent
    Middleware --> Handler["Handler.ServeHTTP(w, r)"]:::success
  

Сравнение протоколов: gRPC vs REST

flowchart LR
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    subgraph REST["REST over HTTP/1.1"]
        RJ["Текстовый JSON (тяжелый парсинг)"]:::warning
        RC["Новый запрос = ожидание или отдельное TCP-соединение"]:::warning
        RU["Слабая типизация (OpenAPI спецификации вручную)"]:::process
    end

    subgraph GRPC["gRPC over HTTP/2"]
        GP["Бинарный Protobuf (быстрая кодогенерация, сжатие)"]:::success
        GM["Мультиплексирование сотен RPC в одном сокете"]:::success
        GS["Двунаправленный стриминг (Client/Server/Bidi)"]:::accent
        GT["Строгий контракт .proto с генерацией Go-интерфейсов"]:::success
    end
  

  1. Как работает HTTP?
  2. Что такое TCP?
  3. Что такое handshake (рукопожатие)?
  4. Что такое keep-alive?
  5. Как работает net/http?
  6. Что такое middleware?
  7. Как работает пул соединений (connection pool)?
  8. Как работает http.Transport?
  9. Что такое IdleConnTimeout?
  10. Что такое TLS и как он работает в Go?
  11. Как сделать HTTP/2 сервер?
  12. Как работает x/net/http2?
  13. Как работает http.Request и http.ResponseWriter?
  14. Что такое http.Handler?
  15. Как реализовать ServeHTTP?
  16. Как обрабатывать таймауты (ReadTimeout, WriteTimeout, IdleTimeout)?
  17. Как работает context в HTTP запросах?
  18. Как отменить HTTP запрос?
  19. Как работает net.Dial?
  20. Что такое net.Conn?
  21. Как работает net.Listen?
  22. Как написать TCP сервер без net/http?
  23. Как обрабатывать WebSocket (gorilla/websocket)?
  24. Как работает gRPC?
  25. В чем преимущества gRPC перед REST?
  26. Как работает Protobuf и сериализация?
  27. Как загружать большие файлы через HTTP?
  28. Как работает докачка файлов (range request)?
  29. Как реализовать потоковую передачу (streaming) через HTTP?
  30. Как работает http.ServeMux и роутинг в Go 1.22+?
  31. Как обрабатывать CORS?
  32. Как работает http.Client с прокси?
  33. Что такое httptrace и как его использовать для отладки?
  34. Как работает graceful shutdown для HTTP сервера?
  35. Как работает SO_REUSEPORT в Go?
  36. Как работает fasthttp и чем он отличается от net/http?
  37. Как обрабатывать multipart/form-data?
  38. Как работает Server-Sent Events (SSE)?
  39. Как настроить keep-alive для клиента и сервера?
  40. Как работает net.LookupHost для разрешения DNS?
  41. Как работает net/http/httputil.ReverseProxy?
  42. Что такое DrainBody и почему важно вычитывать тело ответа до конца?
  43. Как работает http.RoundTripper и как его можно мокать?
  44. Как работать с x-www-form-urlencoded данными?
  45. Как работает h2c (HTTP/2 без TLS)?
  46. Как реализовать "long polling" с помощью Go?
  47. Как тестировать HTTP хендлеры с помощью httptest?
  48. Как ограничить количество входящих соединений на HTTP сервере?

Как работает HTTP?

Краткий ответ (30–60 секунд)

HTTP — текстовый протокол прикладного уровня, работающий по модели запрос-ответ поверх TCP (или TLS). Клиент отправляет запрос, состоящий из строки запроса, заголовков и опционального тела; сервер возвращает статус-код, заголовки и тело. Протокол не хранит состояние, но расширяется механизмами вроде кук, keep-alive и кэширования.

Развёрнутый ответ

HTTP (Hypertext Transfer Protocol) определяет формат сообщений и порядок обмена ими. Запрос состоит из:

  • Метод (GET, POST, PUT, DELETE…), path, версия протокола.
  • Заголовки (Host, Content-Type, Accept, Authorization и др.).
  • Пустая строка (CRLF).
  • Тело (опционально).

Ответ:

  • Версия, статус-код (200, 404…), пояснение.
  • Заголовки (Content-Length, Content-Type, Set-Cookie…).
  • Тело.

Важные версии:

  • HTTP/1.0: по умолчанию одно соединение — один запрос.
  • HTTP/1.1: keep-alive по умолчанию, chunked transfer encoding, пайплайнинг (почти не используется).
  • HTTP/2: бинарный фрейминг, мультиплексирование, server push, сжатие заголовков HPACK.
  • HTTP/3: поверх QUIC на UDP, решает head-of-line blocking на транспортном уровне.

Пример сырого HTTP-запроса (HTTP/1.1):

TEXT
GET /api/users HTTP/1.1
Host: example.com
Accept: application/json

Ответ:

TEXT
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 42

{"id":1,"name":"Alice"}

В Go HTTP реализован пакетом net/http, который скрывает детали парсинга и управления TCP, предоставляя интерфейсы Handler, RoundTripper, Client и Server.

Под капотом (internals)

В net/http сетевое взаимодействие абстрагировано через net.Conn, полученный из net.Dial (клиент) или net.Listen (сервер). Реализация HTTP/1.1 расположена в transport.go (http.Transport) и server.go (http.Server).

  • Для чтения запросов используется bufio.Reader, чтобы искать \r\n\r\n, затем парсинг заголовков в textproto.Reader.
  • Тела читаются обёртками http.body, которые отслеживают закрытие и обеспечивают ограничение размера (MaxBytesReader).
  • http.Transport управляет пулом TCP-соединений и кэширует их для keep-alive; каждое соединение обрабатывается в отдельной горутине.
  • Поддержка HTTP/2 реализована в golang.org/x/net/http2, включая собственный фрейминг и мультиплексирование через http2.Transport и http2.Server.

Типичные ошибки кандидатов

  1. Не вычитывают тело ответа до конца при keep-alive
    Даже если ответ не нужен, прочитанное до конца тело позволяет переиспользовать TCP-соединение. Не вычитанное тело приводит к утечке соединений и нехватке портов. Решение: io.Copy(io.Discard, resp.Body) перед resp.Body.Close().

  2. Забывают закрывать resp.Body у клиента
    Не закрытое тело удерживает соединение открытым и не возвращает его в пул. Это вызывает истощение файловых дескрипторов. Исправление: всегда defer resp.Body.Close().

  3. Неправильное иcпользование Content-Length с chunked
    Если тело не известно заранее и не установлен Content-Length, Go автоматически использует chunked Transfer-Encoding. Некоторые серверы требуют явно задать Content-Length: 0 для пустого тела. Ошибка: не учитывать это при интеграции.

  4. Игнорирование порядка записи заголовков
    Заголовки должны быть записаны до тела: в Go WriteHeader вызывает отправку статус-кода и заголовков; любое изменение после Write игнорируется. Кандидаты часто пытаются установить заголовки после вызова Write.

Возможные дополнительные вопросы интервьюера

  • Как клиент определяет, что ответ окончен?
    По значению Content-Length или Transfer-Encoding: chunked (последовательность чанков, завершающихся нулевым чанком). При отсутствии обоих в HTTP/1.1 соединение может быть закрыто сервером.

  • Чем отличается HTTP/1.1 от HTTP/2 в плане мультиплексирования?
    HTTP/1.1 открывает несколько TCP-соединений для параллельных запросов, что приводит к конкуренции; HTTP/2 мультиплексирует множество потоков в одном TCP-соединении, избавляясь от head-of-line blocking на уровне приложения, но остаётся проблема на уровне TCP (решается HTTP/3).

  • Как работает Upgrade-заголовок для WebSocket?
    Клиент отправляет Upgrade: websocket и соответствующие ключи; сервер отвечает 101 Switching Protocols, и дальнейший обмен идёт в бинарном кадре поверх того же TCP-соединения.


Что такое TCP?

Краткий ответ (30–60 секунд)

TCP (Transmission Control Protocol) — протокол транспортного уровня, обеспечивающий надёжную, упорядоченную, с контролем потока и ошибок доставку байтового потока между приложениями. Работает поверх IP, использует подтверждения (ACK), повторную отправку потерянных пакетов и управление перегрузкой.

Развёрнутый ответ

TCP устанавливает двусторонний канал связи (сокет), гарантируя, что данные дойдут в правильном порядке и без дублирования. Основные механизмы:

  • Тройное рукопожатие (SYN, SYN-ACK, ACK) для установления соединения.
  • Последовательные номера — каждый байт нумеруется, приёмник отправляет ACK с номером следующего ожидаемого байта.
  • Контрольные суммы — для выявления повреждений.
  • Таймеры и ретрансмиссии — если ACK не получен, сегмент пересылается.
  • Окно приёма — управление потоком: получатель сообщает, сколько данных он готов принять.
  • Управление перегрузкой — алгоритмы (Slow Start, Congestion Avoidance, Fast Retransmit/Recovery) для предотвращения перегрузки сети.
  • Четырёхстороннее закрытие (FIN, ACK) для корректного разрыва.

TCP ориентирован на соединение, что делает его идеальным для HTTP, TLS, SSH и т.д. В Go TCP представлен интерфейсом net.Conn из пакета net.

Под капотом (internals)

В Linux TCP реализован в ядре. Go использует системные вызовы через net.Dial("tcp", ...), который вызывает socket(2), connect(2). Чтение/запись — через read(2)/write(2) на файловом дескрипторе. Во время чтения горутина блокируется, но сетевой поллер (epoll/kqueue) позволяет эффективно ожидать событий на многих дескрипторах без блокировки потока ОС. Структура net.Conn в Go обёрнута в *net.TCPConn, которая позволяет настраивать параметры (KeepAlive, NoDelay).

Типичные ошибки кандидатов

  1. Не настройка TCP Keep-Alive для долгоживущих соединений
    Если одна сторона молчит, промежуточные устройства (NAT, фаерволы) могут сбросить состояние. Без keep-alive соединение висит до таймаута уровня приложения. Решение: tcpConn.SetKeepAlive(true) и установка периода.

  2. Предположение, что Write сразу отправляет данные
    Write помещает данные в буфер отправки ядра, а алгоритм Nagle может откладывать отправку маленьких пакетов. Для низкой задержки можно отключить Nagle через SetNoDelay(true).

  3. Неверная обработка коротких чтений
    TCP — потоковый протокол, Read может вернуть меньше запрошенного. Нельзя рассчитывать, что один Read вернёт ровно одно сообщение. Нужно буферизовать и парсить протокол уровня приложения.

  4. Попытка полагаться на RST как на штатный разрыв
    Close() обычно отправляет FIN, ожидает ACK и переходит в состояние TIME_WAIT. При аварийном завершении можно вызвать SetLinger(0) для отправки RST, но это не рекомендуется для обычного потока.

Возможные дополнительные вопросы интервьюера

  • Что такое TIME_WAIT и почему он важен?
    Состояние после активного закрытия, длится 2MSL (обычно 60с), гарантирует, что задержавшиеся сегменты не помешают новому соединению с теми же портами. На высоконагруженных клиентах его переполнение вызывает нехватку локальных портов, решается SO_REUSEADDR или пулом соединений.

  • Чем TCP отличается от UDP?
    UDP — без соединения, не гарантирует доставку и порядок, нет контроля перегрузки. Используется в DNS, стриминге, HTTP/3 (через QUIC).

  • Как работает управление перегрузкой в TCP?
    Основные алгоритмы: Tahoe/Reno/NewReno, CUBIC (по умолчанию в Linux), BBR. Они управляют размером окна перегрузки (cwnd), чтобы избежать переполнения сети.


Что такое handshake (рукопожатие)?

Краткий ответ (30–60 секунд)

Handshake — начальная фаза установления защищённого или надёжного канала. В TCP это тройное рукопожатие (SYN, SYN-ACK, ACK) для синхронизации последовательных номеров. В TLS — более сложный обмен для согласования версии, шифров и аутентификации.

Развёрнутый ответ

Для TCP трёхстороннее рукопожатие:

  1. Клиент отправляет SYN со случайным номером последовательности (seq=x).
  2. Сервер отвечает SYN-ACK: подтверждает x+1 и предлагает свой seq=y.
  3. Клиент отвечает ACK с seq=x+1, ack=y+1.

После этого соединение установлено. Этот процесс исключает старые дубликаты и синхронизирует номера.

TLS рукопожатие (минимальный обзор):

  • ClientHello: версия TLS, наборы шифров, случайные данные, расширения (SNI).
  • ServerHello: выбранный набор, сертификат, случайные данные, запрос сертификата клиента (опционально).
  • Аутентификация и обмен ключами (RSA, DHE, ECDHE).
  • Обе стороны вычисляют сессионные ключи.
  • Завершающие сообщения Finished для подтверждения.

В TLS 1.3 рукопожатие сокращено до 1-RTT, иногда 0-RTT с предварительным ключом.

Под капотом (internals)

В Go TCP-рукопожатие происходит внутри net.Dial("tcp", addr), который блокирует горутину до завершения соединения. При использовании http.Transport с TLS, TLS handshake запускается после установления TCP-соединения, внутри tls.Dial или встроенного клиента. Код в crypto/tls: Client и Server структуры, методы Hand shake(). Используется асинхронная машина состояний (finite state machine). Сертификаты загружаются в tls.Config.

Типичные ошибки кандидатов

  1. Путаница между TCP и TLS handshake
    Кандидаты часто говорят о TLS, когда спрашивают о TCP. Важно чётко разделять: TCP — транспортная синхронизация, TLS — криптографическая. При отладке таймаутов сначала смотрят TCP handshake через netstat или eBPF, затем TLS.

  2. Игнорирование влияния round-trip time (RTT)
    Каждое сообщение handshake добавляет RTT. TLS 1.2 требует 2-RTT, TLS 1.3 — 1-RTT. Приложение должно учитывать это для latency-sensitive сервисов; используйте 0-RTT только для идемпотентных операций, чтобы избежать replay-атак.

  3. Не обновление корневых сертификатов
    В Go системный корневой пул подгружается из ОС; в контейнерах иногда отсутствуют. Ошибка: "x509: certificate signed by unknown authority". Решение: встроить нужный сертификат в tls.Config.RootCAs.

  4. Задание InsecureSkipVerify: true в production
    Отключает проверку цепочки сертификатов, подвергая MITM-атакам. Никогда не использовать в проде, лучше добавить CA в доверенные.

Возможные дополнительные вопросы интервьюера

  • Как работает TCP Fast Open (TFO)?
    Позволяет отправлять данные в SYN-пакете на основе криптографического cookie, сохранённого с предыдущего соединения. Сокращает задержку. В Go поддерживается через настройку сокета, но не через стандартный net напрямую без дополнительных syscall.

  • Что такое Perfect Forward Secrecy в TLS?
    Свойство, при котором компрометация долговременного ключа сервера не раскрывает прошлые сессии. Достигается через эфемерные алгоритмы (ECDHE). В Go это включено по умолчанию для TLS 1.2+.

  • Как в Go установить минимальную версию TLS?
    tls.Config{MinVersion: tls.VersionTLS12}. Рекомендуется задавать явно, чтобы избежать старых уязвимых версий.


Что такое keep-alive?

Краткий ответ (30–60 секунд)

Keep-alive — механизм повторного использования TCP-соединения для нескольких HTTP-запросов вместо открытия нового на каждый запрос. В HTTP/1.1 включён по умолчанию, управляется заголовком Connection: keep-alive. Снижает накладные расходы на TCP рукопожатие и медленный старт.

Развёрнутый ответ

В HTTP/1.0 после ответа сервер закрывал соединение. HTTP/1.1 ввёл постоянные соединения: сервер не закрывает соединение после ответа, а ждёт следующего запроса. Если клиент или сервер хочет разорвать, отправляется Connection: close. Для обнаружения разрыва на уровне TCP используют keep-alive пакеты (TCP keep-alive), но чаще говорят именно о HTTP keep-alive — переиспользовании соединения.

Преимущества:

  • Меньше CPU и памяти на установку соединений.
  • Уменьшение задержки за счёт исключения фазы рукопожатия и slow-start TCP.
  • Лучшая утилизация пропускной способности.

Недостатки: может возникнуть "head-of-line blocking" в HTTP/1.1 из-за последовательной обработки запросов; сервер должен ограничивать максимальное время простоя соединения и количество запросов на соединение.

Под капотом (internals)

В net/http.Transport пул простаивающих соединений организован по ключу (схема, адрес). Когда Client.Do() завершён и тело ответа прочитано до конца, transport пытается вернуть *persistConn в idleConn map (или список). При следующем запросе к тому же хосту Transport берёт готовое соединение из пула, экономя на Dial. За простаивание отвечает IdleConnTimeout. На уровне TCP SetKeepAlive отправляет keep-alive зонды с интервалом, что помогает выявлять мёртвые соединения. В http.Server idle-таймаут (IdleTimeout) определяет, как долго держать простаивающее соединение открытым от клиента.

Типичные ошибки кандидатов

  1. Неверное понимание области действия keep-alive
    Путают HTTP keep-alive (повторное использование TCP для нескольких HTTP запросов) и TCP keep-alive (зонды ОС для обнаружения обрыва). На интервью нужно разграничивать, но знать, что они взаимодополняют: HTTP keep-alive может привести к полуоткрытым соединениям, которые TCP keep-alive обнаруживает.

  2. Не закрывают тело ответа → утечка keep-alive соединений
    http.Transport не может вернуть соединение в пул, пока тело не дочитано и не закрыто. Это приводит к постоянному росту числа соединений. Исправление: io.Copy(io.Discard, resp.Body) + resp.Body.Close().

  3. Установка слишком больших IdleConnTimeout
    Длительное простаивание может занять ресурсы сервера/клиента и привести к закрытию со стороны промежуточного прокси или фаервола, что вызовет ошибки "connection reset". Нужно настраивать таймауты со знанием инфраструктуры (обычно 60-90 сек).

  4. Не использование keep-alive для внутренних сервисов
    По умолчанию http.Server держит keep-alive, но если клиент не переиспользует соединения (каждый раз новый http.Client), то преимущества теряются. Следует использовать глобальный http.Client с настроенным Transport.

Возможные дополнительные вопросы интервьюера

  • Как определить оптимальное значение MaxIdleConnsPerHost?
    Оно должно быть не меньше ожидаемого параллельного числа запросов к одному хосту, но не больше лимита файловых дескрипторов. По умолчанию 2 — это слишком мало; для бэкендов ставят 100 и больше.

  • Как сервер может форсировать закрытие keep-alive соединения?
    Установкой заголовка Connection: close в ответе. В Go: w.Header().Set("Connection", "close"). Также http.Server закроет соединение, если исчерпан MaxHeaderBytes или при фатальной ошибке.

  • Влияет ли HTTP pipelining на keep-alive?
    Это расширение HTTP/1.1, позволяющее отправлять несколько запросов без ожидания ответов, но из-за сложностей реализации почти не применяется; в Go по умолчанию отключён. Без пайплайнинга keep-alive всё ещё эффективен.


Как работает net/http?

Краткий ответ (30–60 секунд)

Пакет net/http предоставляет клиент и сервер HTTP. На стороне сервера: создание http.Server, регистрация обработчиков через http.Handle или ServeMux, вызов ListenAndServe(). Клиент: создание http.Client, выполняющего запросы через http.Transport, который управляет TCP-соединениями, TLS, прокси и редиректами.

Развёрнутый ответ

Основные компоненты сервера:

  • http.Server — структура с адресом, обработчиком (обычно http.DefaultServeMux или свой http.Handler), таймаутами (ReadTimeout, WriteTimeout, IdleTimeout), TLSConfig.
  • ServeMux (мультиплексор) сопоставляет URL-путь с обработчиком. В Go 1.22+ поддерживает методы и именованные сегменты ({name}).
  • http.Handler — интерфейс с методом ServeHTTP(ResponseWriter, *Request).
  • Цепочка обработки: Server.conn в отдельной горутине читает запрос с помощью readRequest(), создаёт response и вызывает обработчик. После возврата горутины возможно повторное использование соединения.

Клиент:

  • http.Client — точка входа, содержит Transport (по умолчанию http.DefaultTransport) для выполнения круглых проходов (round trips), CheckRedirect, Jar (куки).
  • http.Request создаётся http.NewRequest (или http.NewRequestWithContext), затем client.Do(req).
  • DefaultTransport — экземпляр http.Transport с разумными настройками таймаутов, пула соединений и поддержкой HTTP/2.

Под капотом (internals)

Ключевые файлы в net/http:

  • server.go: Serve, ListenAndServe, accept loop. Для каждого входящего соединения запускается go c.serve(ctx).
  • transport.go: RoundTrip логика, пул свободных соединений (idleConn), dialConn для создания нового TCP/TLS.
  • response.go: response структура, реализующая ResponseWriter.
  • request.go: Request, парсинг формы, cookie.
  • h2_bundle.go: содержит встроенный HTTP/2 сервер (в Go 1.6+).

Сервер использует net.Listen для получения net.Listener. В цикле Accept() возвращает net.Conn. Для каждого соединения создаются conn структуры с буферами (bufio.Reader, bufio.Writer). HTTP/1.1 поддерживает keep-alive: после обработки запроса соединение не закрывается, а ожидает следующий запрос, отслеживая таймауты.

Типичные ошибки кандидатов

  1. Использование DefaultServeMux в production без осторожности
    Любая библиотека может зарегистрировать обработчики на http.Handle, что приводит к конфликтам или уязвимостям (например, net/http/pprof). Решение: всегда создавайте явный http.NewServeMux().

  2. Забывают про контекст запроса
    Обработчики, которые запускают длительные операции, должны проверять req.Context().Done(), иначе горутина не завершится при обрыве соединения, утекая. Исправление: пробрасывать контекст в БД, gRPC, etc.

  3. Неверная запись статус-кода и заголовков
    Вызов WriteHeader() посылает статус и все записанные заголовки; любое изменение заголовков после игнорируется. Часто пишут заголовки после Write(), что приводит к нарушению протокола.

  4. Не использование Server.MaxHeaderBytes
    По умолчанию 1MB, но злоумышленник может отправить запрос с огромными заголовками и забить память. Стоит установить разумный лимит.

Возможные дополнительные вопросы интервьюера

  • Чем отличается Handle от HandleFunc?
    Handle принимает http.Handler, HandleFunc — функцию с сигнатурой func(ResponseWriter, *Request), которая автоматически оборачивается в http.HandlerFunc.

  • Как net/http обрабатывает concurrent requests?
    Каждое входящее соединение обслуживается в отдельной горутине. Это масштабируется благодаря шедулеру Go и эффективному сетевому поллеру (epoll/kqueue). Однако при большом количестве соединений может потребоваться лимитирование через семафоры.

  • Можно ли динамически добавлять обработчики в ServeMux после запуска сервера?
    Формально можно, но ServeMux не потокобезопасен для записи. В Go 1.22+ есть mux.Handler() для получения текущего обработчика, но изменение после старта не рекомендуется.


Что такое middleware?

Краткий ответ (30–60 секунд)

Middleware — функция, оборачивающая http.Handler и добавляющая сквозную функциональность: логирование, аутентификацию, трассировку, сжатие, CORS. Обычно имеет сигнатуру func(http.Handler) http.Handler. Может объединяться в цепочки.

Развёрнутый ответ

В Go middleware встраивается как прослойка между мультиплексором и конечным обработчиком. Пример типичного middleware:

Go
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

Применение: mux := http.NewServeMux(); mux.HandleFunc("/", handler); loggedMux := LoggingMiddleware(mux); http.ListenAndServe(":8080", loggedMux).

Middleware может модифицировать запрос (например, добавлять request ID в контекст) или ответ (обёртывать ResponseWriter для захвата статус-кода, как это делает http.ResponseController в Go 1.20+). Можно создавать цепочки с помощью сторонних библиотек (gorilla/mux, chi) или вручную: h = middleware1(middleware2(h)).

Под капотом (internals)

Поскольку в Go http.Handler — интерфейс, обёртывание просто создаёт новый Handler, вызывающий оригинальный. Никакой магии: цепочка вызовов ServeHTTP разворачивается синхронно. Для обёртывания ResponseWriter ранние версии требовали реализации http.Hijacker, http.Flusher. Начиная с Go 1.20, http.ResponseController позволяет писать универсальные обёртки без знания конкретного типа доступа. Middleware может использовать w.(http.Flusher).Flush() для стриминга.

Типичные ошибки кандидатов

  1. Изменение ответа после вызова next.ServeHTTP
    Если middleware пытается установить заголовки или статус-код после того, как обработчик уже вызвал WriteHeader, это игнорируется или вызывает panic. Правильно: модифицировать запрос до вызова next, а ответ перехватывать через обёртку ResponseWriter.

  2. Забывают вызвать next.ServeHTTP
    Тогда запрос не достигает конечного обработчика, клиент получает 200 OK с пустым телом (или таймаут). Явная ошибка, часто при условных блокировках авторизации: нужно либо вызвать next, либо написать ошибку напрямую.

  3. Паника при приведении ResponseWriter к интерфейсу без проверки
    Например, w.(http.Hijacker) — не все ResponseWriter поддерживают хайджекинг, особенно при использовании HTTP/2. Нужно проверять приведение типов: if hijacker, ok := w.(http.Hijacker); ok { ... }.

  4. Middleware, который не гарантирует освобождение ресурсов при панике
    Если middleware аллоцирует ресурс (контекст, транзакцию), но не использует defer, паника в next может оставить ресурс неосвобождённым. Используйте defer внутри своего обработчика.

Возможные дополнительные вопросы интервьюера

  • Как организовать приоритет middleware?
    "Луковичная" модель: внешний обработчик выполняется первым до вызова next и последним после. Например: Recovery -> Logger -> Auth -> Handler. Порядок важен: Recovery должен быть снаружи, чтобы ловить паники во внутренних слоях.

  • Как передать данные через middleware в обработчик?
    Использовать context.WithValue и r = r.WithContext(ctx). Не злоупотреблять: ключи должны быть определёнными типами, а не строками, для избежания коллизий.

  • Что такое http.ResponseController и как он помогает в middleware?
    Позволяет middleware устанавливать таймауты чтения, перезаписывать статус и т.д. без прямого знания конкретного типа ResponseWriter. Появился в Go 1.20: rc := http.NewResponseController(w); можно rc.SetReadDeadline(...) до вызова хендлера.


Как работает пул соединений (connection pool)?

Краткий ответ (30–60 секунд)

Пул соединений — кэш установленных TCP-соединений, используемый http.Transport для повторного применения в последующих запросах к тому же хосту. Это уменьшает задержки на рукопожатия и экономит ресурсы. Управляется параметрами MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeout.

Развёрнутый ответ

http.Transport содержит карту idleConn (тип map[connectMethodKey][]*persistConn), где ключ включает схему (http/https), адрес, прокси-адрес. После завершения запроса, если ответ прочитан до конца и соединение не закрыто сервером, persistConn помещается в пул простаивающих. При следующем RoundTrip транспорт ищет подходящее idle-соединение. Если нет — создаёт новое (ограничено MaxConnsPerHost). Пул также обрабатывает закрытие "мёртвых" соединений и очистку по таймауту.

Параметры:

  • MaxIdleConns — общее число простаивающих соединений во всём транспорте (по умолчанию 100).
  • MaxIdleConnsPerHost — на конкретный хост (по умолчанию 2).
  • IdleConnTimeout — сколько держать незанятое соединение (по умолчанию 90 сек).
  • MaxConnsPerHost — максимальное общее количество соединений с хостом.

Корректное поведение клиента требует закрывать тело ответа (даже если не нужно) и читать его до конца, иначе соединение не возвращается в пул.

Под капотом (internals)

Внутренняя структура persistConn содержит net.Conn, обёрнутый в буферы, и управляет жизненным циклом: чтение ответа, закрытие. В методе tryPutIdleConn после обработки ответа пытается вернуть соединение в t.idleConn. Если пул переполнен (по лимитам), соединение закрывается. Также используются wantConn-запросы для ожидания свободного соединения, когда лимит новых соединений исчерпан. По CloseIdleConnections() все простаивающие закрываются (например, перед сменой DNS).

Типичные ошибки кандидатов

  1. Ошибка с MaxIdleConnsPerHost = 0 (отключение пула)
    Если установлен в 0, idle соединения не кешируются, каждое новое соединение создаётся заново, что убийственно для производительности. Надо устанавливать >0, по умолчанию 2 — маловато для микросервисов.

  2. Не читают тело ответа для keep-alive
    Соединение не возвращается в пул, если остались непрочитанные данные. В итоге транспорт продолжает открывать новые соединения, превышая лимиты и упираясь в порты. Побочный эффект — утечка файловых дескрипторов.

  3. Игнорирование Response.Header.Get("Connection")
    Если сервер присылает Connection: close, такое соединение не переиспользуется. Кандидаты иногда удивляются, почему соединения закрываются. Нужно логировать и анализировать.

  4. Неправильная работа с прокси и пулом
    Для HTTPS через прокси метод CONNECT создаёт туннель; транспорт кэширует эти туннели. Неучёт прокси-аутентификации и ProxyConnectHeader может привести к тому, что все соединения используют первоначальные учётные данные.

Возможные дополнительные вопросы интервьюера

  • Как отключить keep-alive на конкретный запрос?
    Установить в запросе req.Close = true или req.Header.Set("Connection", "close"). Или на уровне транспорта DisableKeepAlives: true.

  • Как диагностировать утечку соединений?
    Использовать net/http/pprof или метрики: посмотреть горутины в net/http.(...persistConn).readLoop, метрики http.Client через expvar или трассировку httptrace.

  • Почему MaxIdleConnsPerHost по умолчанию 2, а не больше?
    Историческое значение, чтобы избежать слишком большого числа открытых idle-соединений к одному хосту при использовании DefaultTransport многими горутинами. В продакшене нужно увеличивать.


Как работает http.Transport?

Краткий ответ (30–60 секунд)

http.Transport — реализация http.RoundTripper, управляющая созданием, кэшированием и пуллингом TCP/TLS соединений для выполнения HTTP-запросов. Обрабатывает прокси, редиректы (опционально), keep-alive, таймауты, TLS. По умолчанию используется http.DefaultTransport, который включает HTTP/2.

Развёрнутый ответ

Transport содержит всю логику отправки запросов по сети. При RoundTrip:

  1. Определяется адрес и подключение (возможно, через прокси).
  2. Из idleConn извлекается свободное persistConn, если есть, или создаётся новое (dialConn).
  3. Запрос записывается в соединение, читается ответ.
  4. После закрытия тела ответа соединение возвращается в пул (если не закрыто).

Важные поля:

  • DialContext — функция для создания сетевого соединения.
  • TLSClientConfig — настройки TLS.
  • MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeout — пул.
  • MaxConnsPerHost — ограничение на общее число соединений (не только простаивающих).
  • ResponseHeaderTimeout — таймаут ожидания заголовков ответа.
  • ExpectContinueTimeout — для 100-continue.
  • Proxy — функция, возвращающая URL прокси.
  • DialTLSContext — собственный TLS-дайлер для HTTP/2 или тонкой настройки.

По умолчанию Transport поддерживает и HTTP/1.1, и HTTP/2 (через x/net/http2). При первом соединении с сервером и наличии поддержки, транспорт обновляет протокол до HTTP/2 (ALPN при TLS или через http2.ConfigureTransport).

Под капотом (internals)

http.Transport — сложная структура с множеством приватных полей (idleMu, idleConn, reqMu, reqCanceler, altProto). При вызове roundTrip создаётся wantConn — запрос на получение persistConn. Для ожидания соединения используется канал; может запускаться горутина dialConnFor. Каждое persistConn имеет две горутины — readLoop и writeLoop, общающиеся через каналы, реализуя симметричную модель обработки. Это позволяет корректно обрабатывать закрытие и отмену. HTTP/2 реализован через altProto с транспортом http2.Transport.

Типичные ошибки кандидатов

  1. Изменение DefaultTransport
    http.DefaultTransport — глобальный объект, его изменение влияет на всех клиентов. Лучше создавать кастомный транспорт и клонировать настройки.

  2. Не настройка MaxConnsPerHost при большом трафике
    По умолчанию 0 (без ограничения). При атаке или всплеске может быть создано слишком много соединений, исчерпывающих лимит ОС по файловым дескрипторам. Рекомендуется устанавливать разумное ограничение.

  3. Отсутствие ResponseHeaderTimeout
    По умолчанию 0 — бесконечное ожидание. Сервер может "залипать". Установите таймаут, иначе рискуете зависшими горутинами.

  4. Несинхронизированная смена DialContext в работающем клиенте
    После изменения DialContext на лету новые соединения будут использовать его, но старые останутся в пуле со старыми параметрами; можно получить сюрпризы с привязкой к сетевому интерфейсу или таймаутами.

Возможные дополнительные вопросы интервьюера

  • Как отключить HTTP/2 в Transport?
    Не инициализировать TLSNextProto и не вызывать http2.ConfigureTransport. Можно принудительно задать TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper) — пустая карта отключит HTTP/2.

  • Чем отличается Transport.DialContext от Transport.DialTLSContext?
    DialContext создаёт сырое TCP-соединение, а TLS наслаивается позже. DialTLSContext — создаёт сразу TLS-соединение (используется для HTTP/2, когда нужен полный контроль над TLS). По умолчанию DialTLSContext вызывает DialContext и делает TLS handshake.

  • Как очистить пул соединений?
    transport.CloseIdleConnections() закрывает все простаивающие соединения. Полезно при изменении DNS или перед остановкой процесса.


Что такое IdleConnTimeout?

Краткий ответ (30–60 секунд)

IdleConnTimeout — поле http.Transport, определяющее максимальное время, в течение которого простаивающее keep-alive соединение может находиться в пуле до закрытия. По истечении соединение удаляется. По умолчанию 90 секунд.

Развёрнутый ответ

Когда соединение перестало использоваться (последний ответ прочитан и тело закрыто), оно помещается в пул idle. Если за IdleConnTimeout не поступит нового запроса к тому же хосту, транспорт посчитает соединение "устаревшим" и закроет его. Это освобождает ресурсы клиента и сервера. Слишком малое значение теряет преимущества keep-alive; слишком большое — может привести к накоплению полуоткрытых соединений (если сервер или NAT уже разорвали). Оптимально чуть меньше, чем серверный keep-alive timeout или таймаут промежуточного устройства (обычно 60-90с). Можно настроить на уровне транспорта:

Go
transport := &http.Transport{
    IdleConnTimeout: 30 * time.Second,
}

На стороне сервера аналог — http.Server.IdleTimeout, который определяет, сколько ждать следующего запроса по keep-alive, прежде чем сервер закроет соединение.

Под капотом (internals)

В Transport.connsPerHost структура persistConn при помещении в пул запускает таймер idleTimer на величину IdleConnTimeout. Если он срабатывает, и соединение всё ещё в пуле, вызывается closeConnIfStillIdle (закрывает). При извлечении соединения из пула таймер останавливается. Сброс таймера происходит при каждом возврате. Параметр также учитывается при массовой очистке CloseIdleConnections() — соединения удаляются независимо.

Типичные ошибки кандидатов

  1. Установка IdleConnTimeout=0 (бесконечность)
    Кажется, что хотим держать соединения вечно, но тогда мёртвые соединения никогда не убираются, что при нестабильной сети ведёт к ошибкам при использовании. 0 означает без таймаута. Лучше всегда ставить явное значение.

  2. Смешивание с TCP Keep-Alive и ожидание, что IdleConnTimeout обнаружит разрыв
    IdleConnTimeout действует только на уровне приложения и не посылает пробные пакеты. Мёртвое соединение может оставаться в пуле до его использования (и ошибки). Нужно комбинировать с TCP keep-alive через net.Dialer.KeepAlive.

  3. Не учитывать, что сервер может закрыть раньше
    Если серверный IdleTimeout = 60с, а клиентский IdleConnTimeout = 120с, то половину времени в пуле будут висеть соединения, которые сервер уже закрыл, но клиент ещё не знает. При попытке использования получим "connection reset". Значение клиента должно быть чуть меньше серверного.

  4. Менять IdleConnTimeout на лету у используемого транспорта
    Это небезопасно без синхронизации, так как старые таймеры уже запущены с предыдущим значением. Лучше создать новый транспорт или выполнить безопасную замену через atomic.

Возможные дополнительные вопросы интервьюера

  • Как IdleConnTimeout влияет на HTTP/2?
    В HTTP/2 одно соединение мультиплексируется, idle timeout применим ко всему соединению. Если нет активных потоков, транспорт закрывает его по таймауту, что может быть дороже из-за пересоздания мультиплексного соединения.

  • Как узнать, что соединение простаивает из-за этого таймаута?
    Использовать httptrace с колбэками GotConn и PutIdleConn, чтобы видеть жизненный цикл соединения.

  • Есть ли аналог для сервера?
    Да, http.Server.IdleTimeout — если соединение не получило ни одного байта за это время, сервер его закрывает.


Что такое TLS и как он работает в Go?

Краткий ответ (30–60 секунд)

TLS (Transport Layer Security) — криптографический протокол, обеспечивающий конфиденциальность, целостность и аутентификацию поверх TCP. В Go реализован пакетом crypto/tls, который интегрирован в net/http через http.Server.TLSConfig и http.Transport.TLSClientConfig.

Развёрнутый ответ

TLS работает слоем над TCP. Этапы:

  1. Handshake: согласование версии, наборов шифров, обмен сертификатами, установление сессионного ключа.
  2. Данные фрагментируются на записи, шифруются и аутентифицируются с помощью AEAD-шифров (AES-GCM, ChaCha20-Poly1305).
  3. При закрытии отправляется alert close_notify.

В Go сервер TLS настраивается так:

Go
server := &http.Server{
    Addr: ":443",
    TLSConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
    },
}
server.ListenAndServeTLS("cert.pem", "key.pem")

Клиентская сторона: http.Client использует DefaultTransport.TLSClientConfig; при необходимости создают кастомный транспорт с TLSClientConfig. По умолчанию проверяется цепочка сертификатов; можно задать свои корневые сертификаты (RootCAs), клиентский сертификат.

Go поддерживает TLS 1.0–1.3; TLS 1.3 включён по умолчанию с Go 1.12+ (оптимизированный handshake, 0-RTT). Пакет crypto/tls написан на чистом Go, что даёт контроль и отсутствие зависимостей от системных библиотек (хотя иногда используется cgo для OpenSSL, но не рекомендуется). Также есть пакет crypto/x509 для работы с сертификатами.

Под капотом (internals)

crypto/tls реализует конечный автомат для handshake. Структура Conn оборачивает net.Conn и содержит ридер/врайтер, работающий с записями TLS. Handshake запускается методами Handshake (клиент) и Handshake (сервер) автоматически при первой операции. Ключи сессии вычисляются из мастер-секрета с помощью HKDF. Для HTTP/2 поддержка ALPN реализуется через расширение NextProto. В Go 1.22 добавлены улучшения по производительности (параллельные вычисления сертификатов). Поддержка 0-RTT реализована через tls.Config.ClientSessionCache.

Типичные ошибки кандидатов

  1. Использование устаревших версий TLS
    Не задают MinVersion, что позволяет TLS 1.0. В продакшене min TLS 1.2. Также нужно исключать уязвимые cipher suites, хотя Go по умолчанию безопасен.

  2. Забывают про InsecureSkipVerify или используют его в проде
    Отключает проверку сертификата — допустимо только для тестов. Для интеграции с самоподписанными сертификатами нужно добавить CA в RootCAs.

  3. Ошибки при загрузке клиентских сертификатов
    Путают PEM-файлы, не указывают пароль для зашифрованного ключа. Правильно: tls.LoadX509KeyPair("cert.pem", "key.pem"). Для encrypted ключа нужно сначала расшифровать x509.DecryptPEMBlock.

  4. Неправильный порядок MinVersion и взаимные протоколы
    Если указать MaxVersion ниже минимальной поддерживаемой сервером, рукопожатие провалится. Лучше оставить MaxVersion=0.

Возможные дополнительные вопросы интервьюера

  • Как настроить mTLS (взаимный TLS)?
    На сервере ClientAuth: tls.RequireAndVerifyClientCert и предоставить ClientCAs как пул доверенных CA. На клиенте добавить клиентский сертификат в TLSClientConfig.Certificates.

  • Как работает Session Tickets в TLS?
    Позволяют клиенту возобновлять сессию без полного повторения обмена ключами, передавая зашифрованный тикет. В Go ServerSessionCache и ClientSessionCache управляют этим.

  • Что делает GODEBUG=tls13=1?
    Включает режим отладки или определённые особенности; для продакшена не требуется.


Как сделать HTTP/2 сервер?

Краткий ответ (30–60 секунд)

В Go HTTP/2 сервер работает "из коробки", если используется TLS: при старте с ListenAndServeTLS сервер автоматически поддерживает HTTP/2 через ALPN. Для не-TLS (h2c) можно использовать golang.org/x/net/http2/h2c. Или сконфигурировать http.Server с TLSConfig.

Развёрнутый ответ

Когда вы запускаете server.ListenAndServeTLS("cert.pem", "key.pem"), стандартная библиотека автоматически регистрирует обработчик для HTTP/2 через http2.ConfigureServer. Это происходит в http.Server.ServeTLS(), которая вызывает http2.ConfigureServer(s, nil). В результате мультиплексируются входящие соединения: после TLS handshake, если клиент объявил поддержку "h2" через ALPN, соединение передаётся во внутренний HTTP/2 сервер. HTTP/1.1 и HTTP/2 работают на одном порту.

Если нужен HTTP/2 без TLS (h2c), нужно использовать x/net/http2 и обёртку h2c.NewHandler:

Go
s := &http.Server{Addr: ":8080", Handler: mux}
http2.ConfigureServer(s, nil) // все равно нужно для некоторых версий
http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))

Под капотом (internals)

В файле server.go при вызове ServeTLS после создания TLS-листенера вызывается http2.ConfigureServer, который устанавливает поля s.TLSNextProto (карта протоколов). В x/net/http2 реализован полный фрейминг: HEADERS, DATA, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE (server push по умолчанию отключён). Каждый стрим обрабатывается как отдельный запрос с ResponseWriter. Поддержка server push может быть выключена из-за низкой утилизации в браузерах. Go 1.22+ перевёл инсталляцию HTTP/2 в стандартную библиотеку, но код всё ещё в x/net/http2.

Типичные ошибки кандидатов

  1. Запуск HTTP/2 без TLS на порту и ожидание, что будет работать
    Без явной конфигурации h2c, сервер будет обслуживать только HTTP/1.1 даже если клиент попытается обновиться (Upgrade: h2c). Нужно явно использовать h2c.

  2. Не настройка IdleTimeout для HTTP/2
    Поскольку через одно TCP-соединение проходит много потоков, преждевременное закрытие из-за idle влияет на все потоки. Серверный IdleTimeout должен быть больше интервала keep-alive пингов, чтобы не разрывать активные соединения с простаивающими стримами.

  3. Ожидание, что server push улучшит производительность без проверки
    Браузеры обычно отключают push, и он может ухудшить latency. В продакшене обычно отключают (http2.Server{MaxConcurrentStreams: ...}) или не используют.

  4. Изменение TLSConfig.NextProtos вручную без понимания последствий
    Порядок важен: ["h2", "http/1.1"] — клиент выберет h2 при возможности. Если оставить только ["http/1.1"], HTTP/2 не будет согласован.

Возможные дополнительные вопросы интервьюера

  • Как отключить HTTP/2 на сервере?
    Не вызывать ListenAndServeTLS, а использовать ListenAndServe с TLS-листенером, вручную настроенным без настройки TLSNextProto, либо http2.ConfigureServer не вызывать и пропустить TLSNextProto. Или задать TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){} пустую карту.

  • Как обрабатывать приоритеты потоков HTTP/2?
    Go стандартный сервер игнорирует приоритеты, что допускается спецификацией. Для сложных сценариев надо смотреть в сторону gRPC, который имеет свои механизмы.

  • Какие преимущества HTTP/2 для бэкенд-сервисов?
    Мультиплексирование избавляет от необходимости открывать множество TCP-соединений, снижая overhead и улучшая управление ресурсами; двунаправленный стриминг через одно соединение (gRPC).


Как работает x/net/http2?

Краткий ответ (30–60 секунд)

Пакет golang.org/x/net/http2 реализует протокол HTTP/2 для Go. Он содержит http2.Transport и http2.Server, интегрирующиеся с net/http через http.Transport.TLSNextProto. Бинарное фреймирование, мультиплексирование потоков, управление потоком на уровне стримов и соединения, HPACK-сжатие заголовков.

Развёрнутый ответ

Ключевые типы:

  • http2.Server — обрабатывает фреймы от клиента, управляет потоками, вызывает http.Handler.
  • http2.Transport — round tripper, устанавливающий HTTP/2 соединения. Поддерживает приоритеты (отправляет, но сервер Go их игнорирует).
  • http2.Framer — чтение/запись фреймов по io.ReadWriter.
  • http2.ClientConn / serverConn — управление состоянием одного TCP-соединения.

HTTP/2 использует бинарные фреймы: типа SETTINGS, HEADERS, DATA, RST_STREAM, PING, GOAWAY, WINDOW_UPDATE. Flow control реализован через окно размером 64K по умолчанию, динамически изменяемое через WINDOW_UPDATE.

Взаимодействие с net/http: при настройке http.Transport вызывается http2.ConfigureTransport, которая задаёт TLSNextProto["h2"], перехватывая TLS-соединение и создавая http2.ClientConn.

Под капотом (internals)

Серверная часть: при получении TLS-соединения с ALPN "h2", функция nextProto создаёт http2.serverConn, который запускает чтение фреймов в цикле. Каждый приходящий HEADERS фрейм открывает новый stream, который оборачивается в http2.requestBody и http2.responseWriter. Структура responseWriter реализует http.ResponseWriter и дополнительно Flusher и Pusher. Сжатие HPACK реализовано в пакете x/net/http2/hpack.

Транспортная часть: http2.Transport.RoundTrip берёт существующее ClientConn из кэша или создаёт новое. Затем создаётся clientStream, записываются заголовки, данные, и ожидается ответ. Пинги и GOAWAY обрабатываются в фоновых горутинах.

Типичные ошибки кандидатов

  1. Неявное предположение, что x/net/http2 не нужен при использовании стандартного net/http
    Стандартный net/http включает HTTP/2 через тот же самый x/net/http2 (вендоринг). Явное импортирование может привести к конфликту версий. Обычно не нужно вручную импортировать, кроме случаев кастомизации.

  2. Игнорирование настройки MaxConcurrentStreams
    По умолчанию сервер ограничивает 250 одновременных потоков на соединение. Если сервис использует долгие стримы (long polling, SSE), можно исчерпать лимит. Следует настраивать http2.Server.MaxConcurrentStreams вверх.

  3. Путаница с GET и body в HTTP/2
    HTTP/2 позволяет тело в любых методах, но net/http может молча игнорировать или запрещать. Не стоит полагаться на это; лучше не отправлять тело в GET.

  4. Неправильная обработка RST_STREAM
    При вызове w.WriteHeader(500) после того как заголовки уже отправлены, может возникнуть RST_STREAM. Клиенты могут получать разрыв вместо ошибки.

Возможные дополнительные вопросы интервьюера

  • Как отключить flow control на сервере?
    Нельзя полностью отключить, но можно выставить очень большие значения http2.Server.MaxUploadBufferPerStream и http2.Server.MaxUploadBufferPerConnection, чтобы минимизировать блокировки.

  • Что такое приоритеты и как они влияют на порядок ответов?
    Клиент может присвоить приоритет потокам; сервер Go их игнорирует, но gRPC-Go может использовать для определения порядка отправки ответов.

  • Как включить server push?
    http2.Server имеет поле IdleTimeout, при использовании Pusher интерфейса w.(http.Pusher).Push(...), но push должен быть разрешён и в конфигурации (http2.Server).


Как работает http.Request и http.ResponseWriter?

Краткий ответ (30–60 секунд)

http.Request представляет входящий HTTP-запрос: метод, URL, заголовки, тело, контекст. Создаётся сервером при разборе сырых данных. http.ResponseWriter — интерфейс для формирования ответа: запись заголовков, статуса и тела. Конкретные реализации зависят от протокола (HTTP/1.1, HTTP/2).

Развёрнутый ответ

http.Request содержит:

  • Method, URL (разобранный URL), Proto (HTTP/1.1), Header (map[string][]string), Body (io.ReadCloser — обёртка над TCP-соединением), Host, RemoteAddr, RequestURI.
  • Context() — возвращает контекст запроса, который можно отменить клиентом или сервером.
  • Form, PostForm — заполняются после ParseForm().
  • MultipartForm — после ParseMultipartForm().
  • TLS — информация о TLS-соединении.
  • Response — nil для входящих, ссылка на ответ при использовании HTTP-редиректов.

Сервер наполняет эти поля после парсинга строки запроса и заголовков. Тело Body может быть лимитировано MaxBytesReader.

http.ResponseWriter — интерфейс:

Go
type ResponseWriter interface {
    Header() http.Header
    Write([]byte) (int, error)
    WriteHeader(statusCode int)
}

Первый вызов WriteHeader фиксирует статус; если не вызван, по умолчанию 200 OK. Заголовки, установленные через Header().Set(), должны быть записаны до WriteHeader или Write. Поддерживаются дополнительные интерфейсы: http.Flusher (немедленная отправка буфера), http.Hijacker (перехват TCP-соединения), http.Pusher (server push для HTTP/2). В Go 1.20+ добавлен http.ResponseController для установки дедлайнов и т.п., не привязываясь к конкретному типу.

Под капотом (internals)

При поступлении запроса server.conn.readRequest() создаёт http.response (приватный тип, реализующий ResponseWriter), который содержит bufio.Writer для буферизованной записи. Ответ пишется в обратном порядке: заголовки, статус-код, тело. В HTTP/2 используется http2.responseWriter, совместимый с ResponseWriter. Для чтения тела запроса используется body структура, обёртывающая io.LimitedReader, если у сервера установлен MaxHeaderBytes.

Типичные ошибки кандидатов

  1. Вызов WriteHeader и последующая попытка изменить заголовки
    Бесполезно, заголовок не попадёт в ответ. Ошибка выявляется при тестировании: ответ не содержит ожидаемого заголовка. Решение: установить все заголовки до вызова WriteHeader.

  2. Чтение r.Body несколько раз без буферизации
    Body — поток, читается один раз. Если middleware прочитает тело для аудита, обработчик его не получит. Используйте io.NopCloser(bytes.NewReader(...)) или сохраняйте в контексте.

  3. Игнорирование возвращаемого значения Write
    Непроверка ошибки записи может привести к тому, что клиент не узнает об обрыве. Ошибка может означать отключение клиента. Лучше логировать, но при ошибке продолжать обработку бессмысленно.

  4. Ошибочное использование w.Write для маленьких порций, вызывая много передач
    Каждый Write может приводить к отдельному TCP-сегменту и фрагментированному HTTP-ответу. Лучше собрать тело в буфер (bytes.Buffer) и записать одним вызовом.

Возможные дополнительные вопросы интервьюера

  • Как правильно установить куки?
    Через http.SetCookie(w, &http.Cookie{...}), что добавляет Set-Cookie заголовок. Можно и вручную в Header().Add("Set-Cookie", ...).

  • Что делает http.Error(w, "Not Found", 404)?
    Устанавливает соответствующий заголовок Conent-Type: text/plain, пишет статус и тело с сообщением. Простой способ вернуть ошибку.

  • Как реализовать Hijack для WebSocket?
    Проверить, что w реализует http.Hijacker; вызвать hijacker.Hijack(), получить net.Conn и далее общаться по WebSocket протоколу.


Что такое http.Handler?

Краткий ответ (30–60 секунд)

http.Handler — интерфейс с единственным методом ServeHTTP(http.ResponseWriter, *http.Request). Любая структура, реализующая его, может обрабатывать HTTP-запрос. Это центральная абстракция обработки входящих запросов в Go.

Развёрнутый ответ

Интерфейс:

Go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Мультиплексор http.ServeMux сам реализует Handler, делегируя зарегистрированным обработчикам. Можно напрямую передать свой Handler в http.ListenAndServe. Функцию можно превратить в обработчик через http.HandlerFunc(f). Вся цепочка middleware строится на замыкании этого интерфейса.

Простота позволяет компоновать поведение: оборачивая один Handler другим, получаем расширяемость без наследования. Пример:

Go
type MyHandler struct{}
func (h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello")
}

Под капотом (internals)

В serverHandler.ServeHTTP (приватном) мультиплексор или пользовательский обработчик вызывается после построения response. Никакой особой магии — вызов виртуального метода. Именно благодаря такому дизайну легко внедрять промежуточные слои. Контекст запроса доступен через r.Context(), его отмена корректно пробрасывается.

Типичные ошибки кандидатов

  1. Создание Handler, который блокирует горутину без контекстной отмены
    Если Handler выполняет долгую операцию и не слушает r.Context().Done(), горутина останется висеть при разрыве клиента. Надо периодически проверять контекст.

  2. Попытка написать ответ после возврата из ServeHTTP (запуск горутины)
    Передача ResponseWriter в другую горутину некорректна: после возврата ServeHTTP фреймворк считает ответ завершённым и может закрыть соединение или повторно использовать. Исключение — hijack.

  3. Реализация Handler, которая паникует без recovery
    Если не обёрнута в recovery middleware, паника обрушит весь процесс. Всегда используйте recover в верхнем уровне.

  4. Ошибочное смешивание http.HandlerFunc и http.Handler
    http.HandlerFunc — адаптер типа функции. Путают, передавая функцию напрямую в переменную, ожидающую Handler, но компилятор помогает.

Возможные дополнительные вопросы интервьюера

  • Можно ли использовать http.ListenAndServe(":80", nil)?
    Да, тогда используется DefaultServeMux. Не рекомендуется в проде из-за проблем безопасности.

  • Как применить разные middleware к разным группам маршрутов?
    Создать несколько ServeMux, каждый обёрнутый нужным middleware, и скомбинировать их на верхнем уровне.

  • Как Handler связан с http.ServeHTTP?
    Handler — интерфейс, ServeHTTP — его метод. http.ServeHTTP — функция-шорткат для быстрого создания обработчика.


Как реализовать ServeHTTP?

Краткий ответ (30–60 секунд)

Реализовать метод ServeHTTP(w http.ResponseWriter, r *http.Request) у своего типа, который удовлетворяет интерфейсу http.Handler. Внутри читаем r, формируем ответ, записываем заголовки и тело через w. Либо использовать http.HandlerFunc с готовой функцией.

Развёрнутый ответ

Пример:

Go
type HelloHandler struct {
    Message string
}

func (h *HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusOK) // можно опустить
    fmt.Fprintf(w, "%s\n", h.Message)
}

Функциональный вариант:

Go
http.Handle("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // ...
}))

При реализации необходимо учитывать:

  • Не блокироваться на неопределённый срок, слушать контекст.
  • Не записывать заголовки после WriteHeader.
  • Корректно обрабатывать ошибки записи.
  • Для потоковых ответов реализовывать Flusher.

Под капотом (internals)

После того как сервер разобрал запрос, он вызывает h.ServeHTTP(rw, req). rw — это http.response с буферизованным writer. При вызове Write данные попадают в буфер, при заполнении или явном Flush отправляются клиенту. При выходе из ServeHTTP сервер проверяет, был ли записан Content-Length или использовано чанковое кодирование, и выполняет завершающие действия (закрытие или keep-alive).

Типичные ошибки кандидатов

  1. Изменение r.URL или r.Method без копии
    Это может повлиять на последующую обработку в middleware, т.к. указатель на один и тот же Request разделяется. Лучше не изменять.

  2. Забывают обработать ошибку Write
    Если запись в w неудачна, дальнейшие действия бессмысленны, но обработка продолжается. Лучше вернуться и не делать дополнительной работы.

  3. Злоупотребление panic внутри ServeHTTP
    Паника приведёт к обрыву соединения с некрасивым завершением. Нужно перехватывать и возвращать HTTP 500 осмысленно.

  4. Игнорирование r.Context() при запуске горутин
    Запущенные горутины могут продолжить работу после отмены запроса, если не передать контекст и не проверять его.

Возможные дополнительные вопросы интервьюера

  • Как в ServeHTTP определить, завершил ли клиент соединение?
    Проверить r.Context().Err() != nil или использовать http.ResponseController для установки дедлайнов на чтение/запись.

  • Как добавить middleware к моему обработчику без изменения кода ServeHTTP?
    Обернуть его: midware(myHandler).

  • Можно ли в одном ServeHTTP обрабатывать несколько запросов?
    Нет, одна горутина на одно соединение в HTTP/1.1 обрабатывает последовательно, но для каждого запроса вызывается ServeHTTP заново.


Как обрабатывать таймауты (ReadTimeout, WriteTimeout, IdleTimeout)?

Краткий ответ (30–60 секунд)

На сервере устанавливают поля http.Server: ReadTimeout — время на чтение всего запроса (включая тело); WriteTimeout — время на запись ответа; IdleTimeout — время ожидания следующего запроса при keep-alive. Эти таймауты защищают от медленных клиентов и утечек ресурсов.

Развёрнутый ответ

Пример конфигурации:

Go
srv := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
}
  • ReadTimeout начинает отсчёт от момента принятия соединения до полного прочтения запроса (включая тело). Если клиент шлёт заголовки медленно, соединение закроется.
  • WriteTimeout охватывает запись ответа от первых заголовков до конца тела. Если запись не укладывается, соединение закрывается.
  • IdleTimeout — после отправки ответа сервер ждёт следующий запрос на том же соединении. По истечении закрывает.

В Go 1.20 появился http.ResponseController для установки дедлайнов на уровне отдельного ответа (например, продлить запись для большого файла), но серверные таймауты всё равно действуют как верхний предел.

Под капотом (internals)

Сервер создаёт conn со счётчиками таймаутов через net.Conn методы SetDeadline, SetReadDeadline, SetWriteDeadline. Таймауты переводятся в соответствие с текущим состоянием конечного автомата соединения. Если клиент завис во время чтения тела, readRequest вернёт ошибку i/o timeout, и соединение будет закрыто. Для WriteTimeout используется deadline на весь период записи ответа. Если обработчик превышает WriteTimeout при написании ответа, соединение обрывается, но горутина может продолжать работу — это надо учитывать.

Типичные ошибки кандидатов

  1. Установка только ReadTimeout без WriteTimeout
    Злоумышленник может открыть соединение и медленно читать ответ, забивая ресурсы. WriteTimeout предотвращает вечно висящие записи.

  2. Неверное понимание, что WriteTimeout включает и ожидание генерации ответа
    Да, время от начала чтения первого байта запроса до завершения записи ответа, включая время работы обработчика. Если обработчик долгий, нужно увеличивать таймаут или переносить работу в фон с ранним ответом.

  3. Игнорирование IdleTimeout для WebSocket
    Для WebSocket idle-таймаут сервера должен быть отключён или очень большим, иначе он закроет соединение во время простоя. Обычно для WebSocket используют отдельный http.Server с IdleTimeout=0.

  4. Изменение таймаутов на лету без перезапуска сервера
    Поля Server небезопасны для изменения после старта. Нужно пересоздавать сервер или использовать сигналы для graceful перезапуска.

Возможные дополнительные вопросы интервьюера

  • Как установить динамический таймаут для конкретного запроса?
    Использовать http.ResponseController.SetWriteDeadline(deadline) или SetReadDeadline в обработчике, но это не отменяет общие Server.ReadTimeout, а лишь может установить более жёсткий лимит.

  • Что произойдёт при превышении ReadTimeout до получения заголовков?
    Соединение будет закрыто, клиент получит ошибку (ECONNRESET или timeout). Если сервер уже частично прочитал, ответа не будет.

  • Как обрабатывать медленных клиентов при отправке большого файла?
    Использовать http.ServeContent с поддержкой range-запросов и установкой WriteTimeout с запасом, либо динамически обновлять deadline через ResponseController.


Как работает context в HTTP запросах?

Краткий ответ (30–60 секунд)

Каждый http.Request содержит контекст (r.Context()), который создаётся сервером при поступлении запроса. Этот контекст отменяется, если клиент разрывает соединение или истекает таймаут сервера. Контекст используется для передачи deadline, значений и отмены операций по цепочке обработки.

Развёрнутый ответ

Сервер создаёт контекст на основе BaseContext (если задан) и обогащает его дедлайном в зависимости от ReadTimeout. Когда соединение разрывается, контекст получает cancel. Обработчик должен проверять ctx.Done() для "долгоиграющих" операций (например, БД, внешние API). Также контекст используется для передачи request-scoped значений (request ID, trace info).

Клиентская сторона: http.Request может быть создан с http.NewRequestWithContext, и RoundTrip гарантирует, что если контекст будет отменён, запрос прервётся (закроется TCP-соединение или прекратится ожидание). Внутренний транспорт использует req.Context() для управления wantConn и т.д.

Под капотом (internals)

При создании http.response на сервере создаётся контекст с функцией отмены, которая связана с conn и состоянием. Структура cancelCtx внутри context пакета привязывается к каналам. Когда соединение закрывается, запускается отмена. В transport контекст запроса обёртывается, и если он отменён до получения ответа, RoundTrip возвращает ошибку context.Canceled. Также организуется race-условие между получением ответа и отменой.

Типичные ошибки кандидатов

  1. Игнорирование контекста в обработчике
    Не проверяя r.Context().Done(), можно бесконечно ждать БД, занимая горутину и удерживая сокеты, даже если клиент отвалился. Обязательно пробрасывать контекст во все вызовы.

  2. Сохранение контекста запроса в глобальной переменной или структуре
    Контекст жив только во время запроса; его передача "на будущее" приводит к утечкам и неопределённому поведению. Для связи между запросами использовать другие механизмы.

  3. Извлечение значений из контекста без типизации ключей
    context.WithValue лучше использовать с неэкспортируемыми типами ключей, чтобы избежать коллизий. Строковые ключи могут конфликтовать с другими пакетами.

  4. Отмена контекста в клиентском коде без учета возможного дрейн-тела
    Если контекст отменён, но ответ уже получен, клиентский код может пропустить закрытие тела, и соединение не вернётся в пул.

Возможные дополнительные вопросы интервьюера

  • Как передать trace ID через контекст?
    В middleware: ctx = context.WithValue(r.Context(), traceIDKey, id); r = r.WithContext(ctx).

  • Что делает http.TimeoutHandler?
    Оборачивает Handler, устанавливая таймаут на выполнение обработчика; при превышении возвращает 503. Основан на контексте: создаёт дочерний контекст с deadline.

  • Как в клиенте прервать запрос по таймауту?
    Создать контекст с таймаутом: ctx, cancel := context.WithTimeout(parent, 5*time.Second); defer cancel(); req, _ := http.NewRequestWithContext(ctx, "GET", url, nil).


Как отменить HTTP запрос?

Краткий ответ (30–60 секунд)

Отменить HTTP-запрос можно через отмену контекста, переданного в запрос: ctx, cancel := context.WithCancel(...), передать в http.NewRequestWithContext. Вызов cancel() прервёт запрос на транспортном уровне (закроет TCP-соединение или перестанет ждать). Альтернативно, использовать http.Client.Timeout.

Развёрнутый ответ

Клиентская отмена:

Go
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "http://example.com", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
    // мог быть context.DeadlineExceeded или Canceled
}

Транспорт реализует отмену через закрытие persistConn или отмену ожидания в пуле. Для TCP-соединения устанавливается дедлайн на чтение, так что блокировка снимается.

Серверная сторона: использовать req.Context().Done() для досрочного выхода из обработчика. Но сам запрос сервером не отменяется, только обработка может быть остановлена.

Под капотом (internals)

В transport.roundTrip создаётся requestCanceler, который при отмене контекста вызывает cancelRequest на persistConn (если уже ассоциировано). persistConn закрывает writeLoop и readLoop, что приводит к разрыву TCP. Если запрос ожидает в очереди wantConn, отмена извлекает его из очереди.

Типичные ошибки кандидатов

  1. Вызов cancel до проверки ошибки Do
    Если cancel отложен с defer cancel(), то после Do контекст всё равно отменяется, что может преждевременно закрыть соединение, если вы ещё не прочитали тело ответа. Лучше вызывать cancel после того, как тело прочитано.

  2. Использование Client.Timeout вместе с контекстом без понимания суммирования
    Client.Timeout оборачивает время на весь Do, включая редиректы. Если контекст имеет свой deadline, они действуют оба, и сработает более ранний.

  3. Не проверка err после Do на context.Canceled
    Часто путают с ошибкой сети, а это может быть отмена по таймауту. Нужно различать для метрик и ретраев.

  4. Забывают, что http.NewRequest не привязывает контекст
    Использование http.NewRequest без контекста создаёт запрос с context.Background(), который никогда не отменяется. Нужно всегда использовать NewRequestWithContext.

Возможные дополнительные вопросы интервьюера

  • Как отменить запрос на стороне сервера?
    Контекст запроса отменяется при разрыве TCP-соединения. Сервер может принудительно отменить через http.ResponseController.SetReadDeadline (на чтение тела), но сам запрос всё равно выполнится.

  • Что такое http.Transport.CancelRequest?
    Устаревший метод для отмены запросов по ключу (до введения контекстов). Не рекомендуется, используйте контекст.

  • Влияет ли отмена на keep-alive соединение?
    При отмене транспорт старается закрыть соединение или не возвращать его в пул, чтобы избежать неконсистентного состояния.


Как работает net.Dial?

Краткий ответ (30–60 секунд)

net.Dial устанавливает сетевое соединение с указанным адресом по протоколу (tcp, udp, unix). Возвращает net.Conn. Использует системные вызовы socket -> connect (для TCP). Может применять net.Dialer с таймаутами, локальным адресом, резолвером.

Развёрнутый ответ

Простой вызов:

Go
conn, err := net.Dial("tcp", "golang.org:80")

Внутри: парсится адрес; если нужно, выполняется DNS-резолвинг через net.Resolver; для каждого IP-адреса пытается установить соединение, может пробовать несколько (happy eyeballs). Для TCP создаётся сокет, вызывается connect(), после успеха возвращается *net.TCPConn.

net.Dialer позволяет детально управлять:

  • Timeout — общее время на установку соединения.
  • Deadline — абсолютное.
  • LocalAddr — исходный адрес.
  • KeepAlive — включение TCP keep-alive и период.
  • Control — функция, получающая файловый дескриптор и позволяющая настраивать параметры сокета (SO_REUSEPORT, SO_MARK).
  • Resolver — кастомный резолвер.

Под капотом (internals)

net.Dialer.DialContext принимает контекст; создаёт неблокирующий сокет, начинает асинхронное соединение, ожидая через сетевой поллер или блокируясь в горутине. Используются системные вызовы: socket, bind (если LocalAddr), connect. Сетевой поллер (epoll/kqueue) позволяет эффективно ждать завершения соединения без привязки потока. При использовании с net.Dial напрямую блокируется горутина, но Go runtime переводит её в ожидание.

Типичные ошибки кандидатов

  1. Использование net.Dial без контекста в современных приложениях
    Нет возможности отмены или таймаута, кроме Timeout Dialer. Лучше всегда использовать Dialer.DialContext.

  2. Путаница с тем, что net.Dial("tcp", "localhost:80") может подключаться по IPv6
    Если хост резолвится в IPv6 и IPv4, реализация happy eyeballs пробует оба адреса; может выбрать не тот, что ожидали. Если нужно явно IPv4, используйте "tcp4".

  3. Невозможность переиспользовать conn после ошибки
    Если Dial вернул ошибку, conn равен nil. Не пытайтесь его закрыть (паника).

  4. Игнорирование настройки KeepAlive при длительных соединениях
    По умолчанию TCP keep-alive может быть отключён или с интервалом часы. NAT и файрволы быстро обрывают "тихие" соединения. Установите KeepAlive: 30 * time.Second.

Возможные дополнительные вопросы интервьюера

  • Как Dialer реализует Happy Eyeballs?
    Пытается подключиться к адресам разных семейств параллельно, выигрывает первый успешный, другой закрывается. Реализовано в dial.go через каналы.

  • Как обернуть net.Conn для проксирования?
    Использовать Dialer.Control? Не совсем; для прокси обычно нужен CONNECT, это делает http.Transport.

  • Чем отличается net.DialTimeout?
    Устаревший шорткат: создаёт Dialer с Timeout и вызывает Dial.


Что такое net.Conn?

Краткий ответ (30–60 секунд)

net.Conn — интерфейс, представляющий двусторонний поток данных (TCP-соединение, UDP, Unix domain socket). Реализует io.Reader, io.Writer, io.Closer, а также методы LocalAddr, RemoteAddr, SetDeadline, SetReadDeadline, SetWriteDeadline. Это фундамент всей сетевой работы в Go.

Развёрнутый ответ

Интерфейс:

Go
type Conn interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
    Close() error
    LocalAddr() Addr
    RemoteAddr() Addr
    SetDeadline(t time.Time) error
    SetReadDeadline(t time.Time) error
    SetWriteDeadline(t time.Time) error
}

Конкретные реализации: *net.TCPConn, *net.UDPConn, *net.UnixConn, *tls.Conn. net.Dial и net.Listen возвращают Conn. Hi-jacking в net/http даёт доступ к Conn, что позволяет реализовать WebSocket или нестандартные протоколы.

Таймауты (deadline) применяются на уровне файлового дескриптора, и по их истечению Read/Write возвращают os.ErrDeadlineExceeded.

Под капотом (internals)

net.Conn — это обёртка над netFD (сетевой файловый дескриптор), которая использует платформо-зависимые syscall. Read и Write сопряжены с горутинами и сетевым поллером: когда сокет не готов, горутина паркуется и пробуждается при событии. Установка deadline преобразуется в таймеры и использует SetDeadline на уровне netFD. Закрытие запускает shutdown и закрытие дескриптора.

Типичные ошибки кандидатов

  1. Забывают про короткие чтения в stream-ориентированном мире
    TCP — поток, Read может вернуть меньше байт, чем длина буфера. Необходимо накапливать данные до полного сообщения.

  2. Неправильная обработка io.EOF
    При закрытии соединения со стороны удалённого хоста Read возвращает io.EOF. Это штатная ситуация, а не ошибка. Кандидаты часто трактуют как критический сбой.

  3. Попытка писать в закрытое соединение без проверки ошибки
    После возврата io.EOF чтение закрыто, но запись может ещё какое-то время проходить (полузакрытое состояние), что приводит к SIGPIPE в Unix или ошибке. Нужно корректно закрыть.

  4. Установка дедлайнов и забывание их сбросить
    Deadline абсолютный; если вы хотите продлить операцию, нужно установить новый deadline. Без сброса следующая операция может сразу провалиться.

Возможные дополнительные вопросы интервьюера

  • Как скопировать данные между двумя net.Conn эффективно?
    Использовать io.Copy в двух горутинах, возможно с io.Pipe или напрямую. Следить за закрытием.

  • Чем отличается Close от CloseWrite в TCP?
    Close закрывает обе стороны. CloseWrite закрывает только отправку (FIN), позволяя принимать данные. В Go у *net.TCPConn есть методы CloseRead, CloseWrite.

  • Можно ли привести net.Conn к *net.TCPConn и зачем?
    Да, через conn.(*net.TCPConn). Позволяет вызывать специфичные методы: SetKeepAlive, SetNoDelay.


Как работает net.Listen?

Краткий ответ (30–60 секунд)

net.Listen создаёт слушающий сокет, привязывается к адресу и начинает принимать входящие соединения, возвращая net.Listener. Вызов Accept() на листенере блокируется до нового соединения и возвращает net.Conn. Close() останавливает приём.

Развёрнутый ответ

Пример:

Go
ln, err := net.Listen("tcp", ":8080")
if err != nil { ... }
for {
    conn, err := ln.Accept()
    if err != nil { ... }
    go handle(conn)
}

Listen делает системный вызов socket, bind, listen. Внутренне net.Listener (интерфейс) реализован *net.TCPListener. Accept() вызывает accept(2), который блокирует до прихода SYN. При закрытии листенера Accept возвращает ошибку (обычно net.ErrClosed).

Для плавной остановки: закрыть листенер, затем дождаться завершения активных соединений.

Под капотом (internals)

При вызове Listen создаётся netFD, устанавливаются опции сокета (SO_REUSEADDR по умолчанию в некоторых ОС). Системный вызов listen устанавливает backlog. Accept использует внутренний поллер для эффективного ожидания. Когда новая горутина вызывает Accept, и нет входящих, она паркуется до события. Это позволяет обслуживать множество листенеров в одном потоке.

Типичные ошибки кандидатов

  1. Запуск сервера с Listen("tcp", ":http") и неожидаемый отказ из-за занятого порта
    Проверять ошибку, иногда порт занят; использовать SO_REUSEPORT для нескольких процессов.

  2. Утечка горутин из-за неправильного выхода из цикла приёма
    Если при ошибке Accept (кроме закрытия) продолжают без паузы, могут заспамить лог и процессор. Надо отличать временные ошибки и ErrClosed.

  3. Некорректное закрытие листенера
    Закрытие листенера не закрывает существующие conn. Надо реализовать graceful shutdown: закрыть листенер, дождаться обработки существующих.

  4. Использование Listen на том же адресе из нескольких процессов без SO_REUSEPORT
    Может не работать (address already in use). Современные ядра поддерживают SO_REUSEPORT для балансировки нагрузки.

Возможные дополнительные вопросы интервьюера

  • Как временно остановить приём новых соединений?
    Закрыть листенер. Или использовать семафор, который приостанавливает цикл, но Accept всё равно будет создавать conn в backlog.

  • Что такое backlog?
    Максимальная очередь ожидающих соединений, которые ещё не приняты через Accept. По умолчанию зависит от ОС, можно настроить через listen(fd, backlog) в Control.

  • В чем разница между Listen и ListenPacket?
    ListenPacket для UDP, не создаёт потоковых соединений.


Как написать TCP сервер без net/http?

Краткий ответ (30–60 секунд)

Использовать net.Listen("tcp", ":port") для создания листенера, в цикле принимать соединения listener.Accept(), получать net.Conn, и в отдельной горутине обрабатывать протокол: читать из conn.Read(), писать в conn.Write(). Реализовать свой формат сообщений.

Развёрнутый ответ

Go
func main() {
    ln, _ := net.Listen("tcp", ":9000")
    defer ln.Close()
    for {
        conn, _ := ln.Accept()
        go handleConn(conn)
    }
}

func handleConn(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)
        if err != nil { break }
        // echo
        conn.Write(buf[:n])
    }
}

Это основа. Для производственных систем нужно: обработка фреймов (признак конца сообщения), таймауты, graceful shutdown, control над числом горутин, буферизация.

Под капотом (internals)

Вся работа с net.Conn напрямую использует системные вызовы. Можно управлять буферами TCP через setsockopt в Control. Например, установить TCP_NODELAY: tcpConn.SetNoDelay(true). Также можно задавать keep-alive. Поскольку вы работаете без net/http, вы получаете полный контроль над потоком и экономите накладные расходы парсинга HTTP.

Типичные ошибки кандидатов

  1. Отсутствие протокола: предположение, что одно Read равно одному сообщению
    TCP — потоковый, сообщения могут склеиваться или разбиваться. Нужно вводить фрейминг (длина + данные, разделитель).

  2. Не обрабатывать частичные записи
    Write может записать не все данные. Нужно использовать io.WriteString или цикл.

  3. Запуск горутины на каждое соединение без лимита
    Легко исчерпать память и горутины под нагрузкой. Нужен семафор или пул воркеров.

  4. Игнорирование закрытия conn при ошибке
    Если чтение вернуло io.EOF, это нормально; стоит закрыть conn. При других ошибках часто тоже закрывают.

Возможные дополнительные вопросы интервьюера

  • Как обработать тысячи соединений с малым количеством горутин?
    Использовать сетевой поллер напрямую (через syscall) или асинхронный ввод-вывод в стиле gnet, но стандартный net уже эффективен.

  • Как передать данные между соединениями (чат)?
    Хранить map соединений, защищать мьютексом, при чтении от одного — писать всем.

  • Как тестировать такой сервер?
    Создавать клиент через net.Dial в тесте, отправлять данные, проверять ответ.


Как обрабатывать WebSocket (gorilla/websocket)?

Краткий ответ (30–60 секунд)

С помощью gorilla/websocket на сервере: создать Upgrader, в HTTP-обработчике вызвать upgrader.Upgrade(w, r, nil), чтобы получить *websocket.Conn. Затем запустить цикл чтения (ReadMessage) и записи (WriteMessage). Клиентская сторона использует websocket.Dial.

Развёрнутый ответ

Серверный фрагмент:

Go
var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true },
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil { ... }
    defer conn.Close()
    for {
        msgType, msg, err := conn.ReadMessage()
        if err != nil { break }
        // обработка
        conn.WriteMessage(msgType, msg)
    }
}

Клиент:

Go
conn, _, err := websocket.DefaultDialer.Dial("ws://server/ws", nil)

Протокол WebSocket работает поверх HTTP upgrade. Соединение переключается на полнодуплексную передачу фреймов (текстовых и бинарных), а также пингов для поддержания keep-alive.

Под капотом (internals)

Библиотека использует http.Hijacker для захвата нижележащего net.Conn после Upgrade. Она реализует фрейминг согласно RFC 6455, управляет маскированием, фрагментацией. Чтение и запись идут в отдельных горутинах внутри ReadMessage/NextReader и WriteMessage. Пинги обрабатываются автоматически, если настроен PingHandler. Поддержка compression (permessage-deflate) включается опционально.

Типичные ошибки кандидатов

  1. Блокировка обработчика без использования горутин для записи
    Если отправка блокируется из-за медленного клиента, чтение останавливается, и сервер теряет возможность получать сообщения. Нужно разделять: одна горутина читает, другая пишет (канал).

  2. Не настроена проверка Origin
    По умолчанию запрещены все источники, нужно явно разрешить, иначе 403.

  3. Забывают обрабатывать пинги вручную, когда не используется ReadMessage
    Контрольные фреймы требуют ответа; библиотека по умолчанию отвечает, но если цикл чтения не вызывается часто, соединение может порваться.

  4. Хранение *websocket.Conn без синхронизации, конкурентная запись
    WriteMessage не потокобезопасен. Нужно либо использовать мьютекс, либо канал, в который пишут несколько отправителей, а единственная горутина производит запись.

Возможные дополнительные вопросы интервьюера

  • Чем отличается WebSocket от Server-Sent Events?
    SSE — односторонняя передача сервер -> клиент поверх HTTP, WebSocket — двусторонняя.

  • Как обрабатывать большое количество WebSocket соединений?
    Использовать эффективный epoll (gorilla/websocket работает на стандартных горутинах), возможно, gobwas/ws для zero-copy, контролировать память и лимиты.

  • Как обойти прокси с WebSocket?
    Использовать wss:// (TLS), чтобы прокси не разрывали HTTP upgrade. Или настраивать прокси.


Как работает gRPC?

Краткий ответ (30–60 секунд)

gRPC — фреймворк удалённого вызова процедур, использующий HTTP/2 как транспорт и Protocol Buffers как язык описания и сериализации. В Go генерируется клиентский и серверный код. Сервер реализует интерфейс, клиент вызывает методы как локальные функции. Поддерживает унарные вызовы, стриминг (клиентский, серверный, двунаправленный).

Развёрнутый ответ

Основные шаги:

  1. Описать сервис в .proto файле.
  2. Сгенерировать Go код с помощью protoc с плагинами protoc-gen-go и protoc-gen-go-grpc.
  3. На сервере: создать gRPC сервер (grpc.NewServer()), зарегистрировать реализацию, Serve(lis).
  4. Клиент: grpc.Dial(target, opts...), создать клиентский stub, вызывать методы.

Стриминг:

  • rpc ServerStreaming(Req) returns (stream Resp) — сервер посылает множество ответов.
  • rpc ClientStreaming(stream Req) returns (Resp) — клиент посылает поток запросов.
  • rpc BidiStreaming(stream Req) returns (stream Resp) — обе стороны.

gRPC использует HTTP/2, что даёт мультиплексирование, сжатие заголовков, приоритеты, flow control на уровне стрима.

Под капотом (internals)

В Go gRPC использует google.golang.org/grpc, который построен поверх x/net/http2 и своего транспорта. Сервер создаёт http2.Server и регистрирует обработчик для /service/method. Заголовки и трейлеры передаются как метаданные. Сериализация через proto.Marshal/Unmarshal. Управление соединениями: grpc.ClientConn поддерживает пул соединений, балансировку, retry, keepalive. Interceptors — аналог middleware для перехвата вызовов.

Типичные ошибки кандидатов

  1. Игнорирование контекста и deadline
    Все RPC методы принимают контекст; если клиент не передаёт таймаут, запрос может зависнуть навсегда. На сервере нужно уважать ctx.Done().

  2. Забывают обработать ошибки стрима
    При рециве от стрима stream.Recv() может вернуть io.EOF (норма) или ошибку; их нужно различать.

  3. Не настроены keepalive параметры
    Без keepalive долгоживущие стримы могут разрываться промежуточными прокси. Следует задать grpc.WithKeepaliveParams с разумными параметрами.

  4. Использование синхронных вызовов для стриминга с большими задержками
    Клиентский стрим: ожидание завершения отправки на CloseAndRecv может блокировать горутину; нужно проектировать модель обработки.

Возможные дополнительные вопросы интервьюера

  • Как работает балансировка нагрузки в gRPC?
    На стороне клиента через resolver и balancer. По умолчанию — pick-first. Можно использовать round-robin: grpc.WithDefaultServiceConfig(...).

  • Чем отличаются unary и streaming интерсепторы?
    Unary обрабатывают один запрос/ответ, стриминговые оборачивают стрим-объекты.

  • Как передать метаданные (аналог заголовков)?
    Через metadata.New(map...) и metadata.AppendToOutgoingContext.


В чем преимущества gRPC перед REST?

Краткий ответ (30–60 секунд)

gRPC обеспечивает строгую типизацию (protobuf), автоматическую генерацию клиента/сервера, высокую производительность за счёт бинарной сериализации и HTTP/2, встроенную поддержку стриминга и обратную совместимость контрактов. REST проще для браузеров и отладки, но проигрывает в производительности и отсутствии схемы.

Развёрнутый ответ

Преимущества gRPC:

  • Схема (contract-first): .proto файлы как источник истины, генерация кода для многих языков.
  • Эффективность: бинарный Protobuf компактнее JSON, особенно для чисел и вложенных структур. HTTP/2 мультиплексирование снижает latency.
  • Стриминг: двунаправленный поток данных из коробки.
  • Обратная совместимость: добавление полей без поломки старых клиентов.
  • Метаданные: аналог заголовков, но с возможностью бинарных значений.
  • Встроенные таймауты/дедлайны, retry, балансировка (через библиотеки).
  • Кодогенерация избавляет от ручного написания сериализации/десериализации, снижая ошибки.

REST остаётся удобным для публичных API, кэширования (HTTP кэш), взаимодействия с браузерами, простоты отладки (curl). В микросервисной архитектуре часто выбирают gRPC для внутренних взаимодействий.

Под капотом (internals)

Сравнение производительности: Protobuf сериализация ~ в 5-10 раз быстрее JSON при парсинге в Go, т.к. не требует reflection и выделяет меньше памяти. HTTP/2 мультиплексирование уменьшает количество TCP-соединений, что снижает потребление ресурсов и задержки.

Типичные ошибки кандидатов

  1. Считать, что gRPC всегда быстрее при маленьких сообщениях
    Накладные расходы HTTP/2 фреймов и сжатия могут быть выше простого HTTP/1.1 с JSON для очень коротких сообщений. Важно профилировать.

  2. Игнорирование необходимости JSON API для внешних потребителей
    Заворачивать gRPC в gRPC-gateway для REST, но не продумав конвертацию ошибок и метаданных. Это усложняет интеграцию.

  3. Недооценка сложности стриминга
    Двунаправленный стриминг требует корректной обработки горутин и backpressure, что не всегда требуется бизнес-логике.

  4. Забывают про брейкинг-изменения
    Несмотря на обратную совместимость Protobuf, переименование полей или пакетов ломает wire-формат.

Возможные дополнительные вопросы интервьюера

  • Как обеспечить REST API из gRPC сервиса?
    Использовать grpc-gateway для генерации reverse-proxy, который транслирует REST в gRPC.

  • Какие недостатки у gRPC?
    Сложность отладки (нужен grpcurl), отсутствие кэширования, поддержка браузеров через grpc-web, больший размер бинарного кода.

  • Как работает дедлайн/таймаут в gRPC и передаётся ли он на сервер?
    Да, deadline отправляется в grpc-timeout заголовке; сервер может проверить оставшееся время через ctx.Deadline().


Как работает Protobuf и сериализация?

Краткий ответ (30–60 секунд)

Protocol Buffers — язык описания данных и бинарный формат сериализации. Компилятор protoc генерирует Go структуры с методами Marshal/Unmarshal. Сериализованные данные компактны и обратно совместимы. Protobuf использует теги полей (field numbers) для идентификации в бинарном потоке.

Развёрнутый ответ

Схема:

PROTO
message Person {
  string name = 1;
  int32 age = 2;
}

Генерация: protoc --go_out=. person.proto. Полученные структуры реализуют proto.Message. Для кодирования используется формат wire type:

  • Varint — для int32, int64, uint, bool, enum.
  • Length-delimited — строки, embedded сообщения, bytes.
  • 64-bit / 32-bit фиксированной длины.

Каждое поле кодируется как ключ (field_number << 3) | wire_type, за которым следует значение. Благодаря field numbers можно добавлять новые поля, не ломая старых парсеров. Неизвестные поля сохраняются для пересылки (в Go не всегда). В v3 все поля опциональны с нулевыми значениями.

Под капотом (internals)

Реализация в google.golang.org/protobuf (reflect-based, предпочтительный) и старый github.com/golang/protobuf. Marshal проходит через encoder с буфером, записывая varint поля. Unmarshal парсит и заполняет структуру. В Go 1.20+ появились улучшения производительности. При сериализации пустые поля не записываются, что экономит место.

Типичные ошибки кандидатов

  1. Изменение field number существующих полей
    Это ломает совместимость, т.к. сериализованные данные с старыми номерами будут интерпретированы неверно. Нельзя переиспользовать номера.

  2. Ожидание, что nil и пустой слайс различаются
    В proto3 нет представления nil-поля для слайсов; пустой слайс сериализуется как отсутствие поля. При десериализации будет пустой слайс.

  3. Неправильное использование oneof
    Поле oneof может содержать только одно значение; установка другого сбрасывает предыдущее. Забывают обрабатывать это в бизнес-логике.

  4. Не учитывают, что map — repeated message для wire
    Map кодируются как repeated сообщения с key/value полями; порядок итерации не гарантирован.

Возможные дополнительные вопросы интервьюера

  • Что такое gRPC-gateway?
    Инструмент генерации REST proxy для gRPC-сервиса; использует обратные аннотации в .proto.

  • Как обрабатывать unknown fields в Go?
    При маршалинге они игнорируются, но если нужно сохранять, можно использовать proto.Unmarshaler с DiscardUnknown: false.

  • Чем Protobuf лучше JSON для межсервисного общения?
    Быстрее, меньше размер, строгая схема, бинарный формат избегает проблем с экранированием.


Как загружать большие файлы через HTTP?

Краткий ответ (30–60 секунд)

Использовать multipart/form-data с потоковой обработкой. В обработчике r.ParseMultipartForm(32 << 20) (лимит памяти), затем r.MultipartForm.File["field"], или более продвинуто: r.MultipartReader() для поточного чтения частей. Файл сохранять на диск или в облачное хранилище чанками, не загружая целиком в память.

Развёрнутый ответ

Пример с MultipartReader:

Go
reader, err := r.MultipartReader()
for {
    part, err := reader.NextPart()
    if err == io.EOF { break }
    if part.FormName() == "file" {
        dst, _ := os.Create("upload.dat")
        defer dst.Close()
        io.Copy(dst, part) // копирует поток, не загружая весь в ОЗУ
    }
}

Таким образом файл читается по мере получения. Важно настроить http.Server.MaxHeaderBytes и таймауты (ReadTimeout), чтобы медленные клиенты не занимали ресурсы.

Для очень больших файлов можно совместить с range upload (возобновление), но HTTP сам не поддерживает resume; нужно реализовать на уровне приложения (проверка полученных чанков).

Под капотом (internals)

При multipart чтении net/http использует mime/multipart.Reader, который разбирает границы и выдаёт Part с телом как io.Reader, обёрнутый в io.LimitedReader — лимит размера файла не задан, но можно обернуть самому в io.LimitReader. Данные читаются прямо из r.Body (TCP-соединения), буферизация минимальна.

Типичные ошибки кандидатов

  1. Использование ParseMultipartForm с большим лимитом памяти
    Например 128 << 20 — все файлы загружаются в оперативную память или временные файлы. Лучше обрабатывать потоково.

  2. Не ограничивают размер загружаемого файла
    Злоумышленник может отправить терабайт, израсходовав диск. Обязательно обернуть io.LimitReader с разумным максимумом.

  3. Выделение огромного буфера для каждого соединения
    Например, io.CopyBuffer с гигантским буфером. Использовать дефолтный (32KB) или системный размер.

  4. Игнорирование ошибок при записи файла
    Если диск заполнен, запись прерывается, но клиент может не узнать. Надо прервать чтение и ответить ошибкой.

Возможные дополнительные вопросы интервьюера

  • Как реализовать прогресс загрузки?
    На клиенте — использовать io.TeeReader для отчёта о прочитанных байтах. На сервере сложнее, так как стандартный MultipartReader не даёт общего прогресса без разбора частей; можно читать сырой поток и парсить границы вручную.

  • Как защититься от слишком большого количества запросов?
    Rate limiting на уровне соединений, ограничение размера body через http.MaxBytesReader.

  • Как обрабатывать многопоточную загрузку одного файла?
    Сервер может не поддерживать, нужно реализовать чанки на уровне приложения с координацией.


Как работает докачка файлов (range request)?

Краткий ответ (30–60 секунд)

HTTP поддерживает частичную загрузку через заголовок Range: bytes=start-end. Сервер в ответе со статусом 206 Partial Content возвращает запрошенный фрагмент и заголовки Content-Range, Accept-Ranges. В Go на сервере используется http.ServeContent, который автоматически обрабатывает Range. Клиент может задать Range заголовок при запросе.

Развёрнутый ответ

Клиент:

Go
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Range", "bytes=0-1023")
resp, _ := client.Do(req)
if resp.StatusCode == 206 {
    body, _ := io.ReadAll(resp.Body)
}

Сервер:

Go
http.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) {
    http.ServeContent(w, r, "filename", fileModTime, fileReaderSeeker)
})

ServeContent автоматически обрабатывает заголовки If-Match, If-None-Match, If-Modified-Since, Range, устанавливает правильные Content-Range, обрабатывает многодиапазонные запросы (Range: bytes=0-100,200-300).

Под капотом (internals)

ServeContent определяет, реализует ли контент io.ReadSeeker; затем разбирает диапазон, устанавливает Content-Range: bytes start-end/total. Если Seeker не доступен, возвращает ошибку. При ошибках в диапазонах отдаёт 416 Range Not Satisfiable. Мультидиапазонные запросы возвращаются с multipart/byteranges.

Типичные ошибки кандидатов

  1. Использование статического файлового сервера без Seeker
    Если источник не поддерживает Seek (например, os.Stdin), ServeContent вернёт ошибку. Надо предоставлять Seeker.

  2. Неправильное вычисление длины содержимого
    При использовании своих реализаций, важно корректно указывать Content-Length общий и для диапазона.

  3. Не обрабатывать If-Range
    Условные Range запросы позволяют клиенту возобновить загрузку только если файл не изменился. Игнорирование может привести к повреждённым данным.

  4. Хранение данных в памяти для раздачи больших файлов
    Передача bytes.Reader на весь файл в памяти — плохо. Используется *os.File или кастомная обёртка, реализующая ReadSeeker (например, для объектов в S3 через range запросы к хранилищу).

Возможные дополнительные вопросы интервьюера

  • Как отдавать несколько диапазонов одновременно?
    ServeContent поддерживает multiple ranges: устанавливает Content-Type: multipart/byteranges; boundary=... и пограничные разделители.

  • Как обрабатывать запросы на возобновление загрузки после разрыва?
    Клиент сохраняет количество принятых байт, шлёт Range: bytes=N-. Сервер отвечает с 206.

  • Какие статус-коды кроме 206 могут быть?
    200 (если Range не поддерживается или запрошен весь файл), 416 (некорректный диапазон).


Как реализовать потоковую передачу (streaming) через HTTP?

Краткий ответ (30–60 секунд)

Потоковая передача — отправка ответа по мере генерации данных, без ожидания полного тела. Достигается: не устанавливать Content-Length (будет chunked Transfer-Encoding), периодически вызывать Flush() для отправки накопленного буфера клиенту. Либо использовать Server-Sent Events (SSE) или long polling.

Развёрнутый ответ

Пример стриминга JSON-объектов:

Go
func streamHandler(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "text/plain")
    for i := 0; i < 10; i++ {
        fmt.Fprintf(w, "data: %d\n", i)
        flusher.Flush()
        time.Sleep(1 * time.Second)
    }
}

Клиент читает поток через resp.Body как io.Reader. Сервер автоматически использует chunked кодирование при отсутствии Content-Length. Для отправки больших бинарных данных можно копировать из источника с флашем через io.CopyBuffer и периодический Flush, если буферизация позволяет.

Под капотом (internals)

Когда обработчик пишет в ResponseWriter без установки Content-Length, серверный http.response накапливает данные в буфере (bufio.Writer размером 4КБ). При вызове Flush буфер сбрасывается в TCP-соединение и отправляется чанк (шестнадцатеричный размер, \r\n, данные, \r\n). Клиент видит chunked-ответ. В HTTP/2 используется фрейминг, тоже поддерживается.

Типичные ошибки кандидатов

  1. Не проверяют, поддерживается ли Flusher
    При использовании HTTP/2 или нестандартных обёрток ResponseWriter может не реализовывать Flusher, тогда паника или отсутствие стриминга. Нужно проверять if f, ok := w.(http.Flusher); ok { f.Flush() }.

  2. Отсутствие явного Content-Type до первого Flush
    После первого Flush заголовки уже отправлены; установка Content-Type позже игнорируется.

  3. Слишком частый Flush без буферизации
    Каждый вызов Flush может порождать маленький TCP-сегмент и заголовок чанка, снижая эффективность. Можно буферизировать небольшие записи вручную.

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

Возможные дополнительные вопросы интервьюера

  • Как реализовать бесконечный стрим с heartbeat?
    Периодически отправлять комментарии (SSE) или пустые чанки (пробелы), чтобы keep-alive не разорвал соединение.

  • Как передавать бинарные данные (например, видео) стримом?
    Использовать io.Copy с кастомным writer, который периодически флашит, или использовать HTTP/2 push.

  • Чем chunked encoding отличается от Content-Length: 0?
    Chunked позволяет отправлять тело неизвестного размера; клиент определяет конец по нулевому чанку.


Как работает http.ServeMux и роутинг в Go 1.22+?

Краткий ответ (30–60 секунд)

http.ServeMux — HTTP-мультиплексор, сопоставляющий URL с зарегистрированными Handler. В Go 1.22+ появились улучшения: поддержка HTTP-методов (GET /, POST /) и именованных сегментов пути (/user/{id}), с извлечением через r.PathValue("id"). Нарушение сопоставления теперь приводит к 405 Method Not Allowed или 404.

Развёрнутый ответ

Новый синтаксис:

Go
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "User %s", id)
})
mux.HandleFunc("POST /users", createUser)

Роутинг базируется на дереве путей, аналогичном pat в стандартной библиотеке. При совпадении шаблона обрабатывается метод. Если путь совпал, но метод не тот, возвращается 405. {name} может ограничиваться регулярным выражением: {id:[0-9]+}. В конце можно указать {$} для точного совпадения без префикса.

PathValue доступен только в обработчике, связанном с конкретным шаблоном. Обработчики по-прежнему можно регистрировать через Handle и Handler.

Под капотом (internals)

Реализовано в net/http через пакет internal/routing (не экспортируется). При регистрации строится дерево радикс-триев с учётом методов. При поступлении запроса мультиплексор ищет наиболее специфичное совпадение. Приоритет: точное совпадение > сегмент с joker {...} > завершающий {$}. Вся логика в ServeHTTP ServeMux. r.PathValue хранит захваченные значения в приватном поле *Request (устанавливается мексом).

Типичные ошибки кандидатов

  1. Путаница с PathValue в middleware
    Если middleware оборачивает ServeMux, r.PathValue не доступен на уровне middleware (только внутри зарегистрированного обработчика). Решение: извлекать ID в самом обработчике или использовать контекст.

  2. Не указан {$} для конечного пути
    Например, GET /users/ без {$} будет совпадать с /users/anything, что может быть нежелательно. Надо использовать GET /users/{$} для точного совпадения.

  3. Пересекающиеся шаблоны
    GET /users/{id} и GET /users/new — порядок регистрации не важен? Мультиплексор решает по приоритету: точное совпадение (static) приоритетнее. Но лучше избегать неоднозначности.

  4. Использование HandleFunc без метода в старом стиле
    Если просто mux.HandleFunc("/users", ...), то он сработает для всех методов, что может нарушить REST. Рекомендуется указывать методы.

Возможные дополнительные вопросы интервьюера

  • Как работают ограничения на сегменты?
    {id:[0-9]+} — сегмент должен соответствовать регулярному выражению; иначе возвращается 404.

  • Можно ли использовать {:id}?
    Нет, только {name} или {name:regexp}.

  • Как быть с множеством методов для одного пути?
    Зарегистрировать несколько обработчиков с разными методами, как в примере выше.


Как обрабатывать CORS?

Краткий ответ (30–60 секунд)

CORS (Cross-Origin Resource Sharing) управляется HTTP-заголовками. В Go можно использовать middleware, который добавляет Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers для ответов и обрабатывает preflight (OPTIONS). Либо пакет github.com/rs/cors.

Развёрнутый ответ

Простая реализация:

Go
func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

rs/cors предоставляет готовый cors.Cors и удобнее: настраивается разрешёнными origins, заголовками, разрешает preflight с кэшированием (Access-Control-Max-Age).

Под капотом (internals)

Middleware перехватывает запрос до основного обработчика. При preflight (OPTIONS с Origin и Access-Control-Request-Method) не доходит до основного кода. Браузеры отправляют preflight для "сложных" запросов (не simple). В Go нужно ответить 204 и необходимыми заголовками. Некоторые фреймворки (chi, gin) имеют встроенные обработчики.

Типичные ошибки кандидатов

  1. Использование Access-Control-Allow-Origin: * с credentials
    Если withCredentials: true, нельзя использовать *. Нужно явно указать origin и Access-Control-Allow-Credentials: true.

  2. Не обработка preflight запросов, которые приходят до основного
    Без ответа браузер блокирует основной запрос. Middleware должен или возвращать 204, или передавать дальше, где обработчик может вернуть неверный статус.

  3. Путаница с заголовками на стороне клиента
    Клиент отправляет Access-Control-Request-Headers; сервер должен их разрешить и отразить в Access-Control-Allow-Headers. Иногда забывают разрешить Authorization.

  4. Игнорирование Vary заголовка
    Прокси-серверы могут кэшировать ответ; чтобы корректно кэшировать CORS ответы, нужен Vary: Origin.

Возможные дополнительные вопросы интервьюера

  • Что такое simple request?
    GET, HEAD, POST с определёнными заголовками (Content-Type только text/plain, multipart/form-data, application/x-www-form-urlencoded) — не вызывают preflight.

  • Как разрешить несколько origins?
    Нельзя перечислить через запятую, можно динамически проверять Origin и копировать его в Allow-Origin.

  • Как работает Access-Control-Max-Age?
    Указывает, сколько секунд браузер может кэшировать preflight-ответ.


Как работает http.Client с прокси?

Краткий ответ (30–60 секунд)

http.Client использует Transport.Proxy функцию для определения URL прокси. По умолчанию ProxyFromEnvironment читает переменные HTTP_PROXY, HTTPS_PROXY, NO_PROXY. Запросы к HTTPS отправляются методом CONNECT, создавая TCP-туннель. Можно задать свой прокси URL через Proxy: http.ProxyURL(...).

Развёрнутый ответ

Настройка явного прокси:

Go
transport := &http.Transport{
    Proxy: http.ProxyURL(proxyURL),
}
client := &http.Client{Transport: transport}

Для HTTPS транспорт устанавливает TCP-соединение с прокси, отправляет CONNECT host:port HTTP/1.1, прокси устанавливает туннель, после чего транспорт запускает TLS handshake итп. Поддерживаются и HTTP-прокси (для HTTP-запросов), которые просто перенаправляют запрос. Аутентификация поддерживается через заголовок Proxy-Authorization.

Также можно использовать ProxyConnectHeader для передачи специфичных заголовков в CONNECT.

Под капотом (internals)

Функция Proxy вызывается при каждом dial. Если возвращает не nil, транспорт не устанавливает обычное TCP-соединение, а вызывает proxyDial (конкретно для схемы). connectMethod управляет логикой. ProxyConnectHeader позволяет добавить заголовки, например для basic auth. При keep-alive прокси-соединения тоже кэшируются в пуле.

Типичные ошибки кандидатов

  1. Забывают про NO_PROXY
    Когда программа запускается в окружении с глобальными переменными прокси, внезапные запросы к localhost идут через прокси. Нужно проверить и добавить исключения.

  2. Неправильная обработка HTTPS через прокси с самоподписными сертификатами
    Прокси может разрывать TLS и подменять сертификат. По умолчанию Go проверяет сертификат конечного сервера. Если нет доверия, ошибка.

  3. Игнорирование ProxyConnectHeader для авторизации прокси
    Если прокси требует авторизации, нужно установить Proxy-Authorization заголовок в ProxyConnectHeader или в URL.

  4. Использование Proxy: nil без ProxyFromEnvironment
    Явно задав nil, отключается использование переменных окружения, что обычно нежелательно для гибкости.

Возможные дополнительные вопросы интервьюера

  • Как задать разные прокси для разных хостов?
    В функции Proxy анализировать URL запроса и возвращать соответствующий адрес прокси.

  • Что произойдёт, если прокси недоступен?
    Ошибка при CONNECT или dial; RoundTrip вернёт ошибку.

  • Поддерживается ли SOCKS прокси?
    Да, через golang.org/x/net/proxy; создаётся dialer, который передаётся в Transport.DialContext.


Что такое httptrace и как его использовать для отладки?

Краткий ответ (30–60 секунд)

httptrace — пакет для трассировки HTTP-запросов: захват событий (DNS, TCP, TLS, got conn, wrote request, got first byte). Позволяет диагностировать задержки и ошибки. Используется через httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{...}).

Развёрнутый ответ

Пример:

Go
trace := &httptrace.ClientTrace{
    DNSDone: func(info httptrace.DNSDoneInfo) {
        fmt.Printf("DNS resolved: %v\n", info.Addrs)
    },
    GotConn: func(info httptrace.GotConnInfo) {
        fmt.Printf("Connection reused: %v\n", info.Reused)
    },
    TLSHandshakeDone: func(state tls.ConnectionState, err error) {
        fmt.Printf("TLS handshake done\n")
    },
    GotFirstResponseByte: func() {
        fmt.Printf("First byte received\n")
    },
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
http.DefaultClient.Do(req)

Выводит временные метки, позволяя профилировать медленные этапы. В production можно записывать в лог или метрики.

Под капотом (internals)

ClientTrace содержит множество колбэков, которые вызываются транспортом и net пакетом в ключевых точках: GetConn (перед открытием), DNSStart/DNSDone, ConnectStart/ConnectDone, TLSHandshakeStart/Done, WroteRequest, GotFirstResponseByte. Трассировка реализована через контекст: httptrace.ContextClientTrace(ctx) извлекает зарегистрированный трейс. Производительность: если колбэк nil, оверхед минимален.

Типичные ошибки кандидатов

  1. Забывают передать контекст в запрос
    Трассировка привязывается к контексту; без WithClientTrace и использования контекста в NewRequest, колбэки не вызовутся.

  2. Сохранение контекста с trace на длительный срок
    Контекст содержит ссылки, что может удерживать память; после запроса контекст нужно отменять.

  3. Избыточное логирование в колбэках для каждого запроса
    Запись в лог на каждый пакет может замедлить систему и создать шум. Использовать выборочно или с агрегацией.

  4. Неправильное использование GotConn для суждения о переиспользовании
    Reused показывает, что соединение взято из idle пула; бывают ситуации, когда соединение не reused из-за таймаутов, но это нормально.

Возможные дополнительные вопросы интервьюера

  • Как измерить время DNS-запроса?
    Запомнить время в DNSStart, вычислить разницу в DNSDone.

  • Как с помощью трассировки найти утечку соединений?
    Считать PutIdleConn и GotConn — соотношение покажет, возвращаются ли соединения в пул.

  • Можно ли использовать httptrace на серверной стороне?
    Нет, только для клиента. На сервере можно использовать net/http/httptest и middleware для логирования.


Как работает graceful shutdown для HTTP сервера?

Краткий ответ (30–60 секунд)

Graceful shutdown позволяет завершить HTTP-сервер без обрыва активных запросов: server.Shutdown(ctx) закрывает листенер, не принимает новые соединения, ждёт завершения текущих запросов или истечения контекста. После этого сервер завершается.

Развёрнутый ответ

Паттерн:

Go
srv := &http.Server{Addr: ":8080"}

go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Printf("Server forced to shutdown: %v", err)
}

Shutdown вызывает Close на листенере, затем ждёт, пока все активные соединения завершат обработку (горутины обслуживания). Если контекст истекает, соединения обрываются. Важно, чтобы обработчики учитывали контекст запроса и завершались при отмене.

Под капотом (internals)

Shutdown устанавливает состояние сервера в shuttingDown. В цикле Accept после закрытия листенера возвращается http.ErrServerClosed. Для каждого активного соединения Serve горутина ожидает завершения. Реализован механизм отслеживания через server.wg (WaitGroup) для горутин обслуживания и пула соединений. При истечении контекста оставшиеся соединения закрываются принудительно.

Типичные ошибки кандидатов

  1. Игнорирование ошибки http.ErrServerClosed при ListenAndServe
    После Shutdown ListenAndServe возвращает http.ErrServerClosed; если её не обработать, программа запаникует или некорректно завершится.

  2. Не передача контекста с таймаутом в Shutdown
    Shutdown(nil) или context.Background() может висеть бесконечно, если обработчики не завершаются. Нужен timeout.

  3. Блокирующие обработчики, игнорирующие r.Context().Done()
    Тогда Shutdown не сможет корректно завершить их, пока не истечёт таймаут, что вызывает принудительное закрытие.

  4. Запуск нескольких вызовов Shutdown
    Повторный вызов вызовет панику; нужно синхронизировать.

Возможные дополнительные вопросы интервьюера

  • Как корректно завершить базу данных после сервера?
    После Shutdown закрыть соединения БД.

  • Что такое RegisterOnShutdown?
    Метод http.Server, позволяющий зарегистрировать функции, выполняемые при Shutdown.

  • Можно ли использовать Close вместо Shutdown?
    Close немедленно обрывает все соединения, что нежелательно.


Как работает SO_REUSEPORT в Go?

Краткий ответ (30–60 секунд)

SO_REUSEPORT — опция сокета, позволяющая нескольким процессам или потокам одновременно слушать один и тот же адрес и порт. В Linux ядро распределяет входящие соединения между ними. В Go включается через net.ListenConfig.Control, где вызывается syscall.SetsockoptInt.

Развёрнутый ответ

Пример:

Go
var lc net.ListenConfig
lc.Control = func(network, address string, c syscall.RawConn) error {
    var operr error
    err := c.Control(func(fd uintptr) {
        operr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
    })
    if err != nil { return err }
    return operr
}
ln, _ := lc.Listen(context.Background(), "tcp", ":8080")

После этого несколько экземпляров одного приложения могут разделять нагрузку. Kernel распределяет соединения по модели round-robin на основе хеша (пять кортежей). Это улучшает масштабирование и позволяет бесшовный перезапуск без потери порта.

Под капотом (internals)

В Linux SO_REUSEPORT введён в ядре 3.9, использует reuseport механизм. Когда сокет помечен, ядро позволяет нескольким сокетам bind один адрес. При поступлении SYN ядро выбирает один из слушающих сокетов (по хешу). Это отличается от SO_REUSEADDR, который только разрешает bind того же адреса после TIME_WAIT. В Go net.ListenConfig позволяет задать Control до bind. На уровне net/http для сервера можно использовать Server.ListenAndServe с предварительно созданным листенером.

Типичные ошибки кандидатов

  1. Путаница с SO_REUSEADDR
    SO_REUSEADDR позволяет переиспользовать адрес в TIME_WAIT, но не разрешает нескольким слушать одновременно. SO_REUSEPORT решает другую задачу.

  2. Ожидание round-robin без учёта хеширования
    Ядро распределяет соединения детерминированно по хешу, что может привести к неравномерности; не всегда round-robin.

  3. Использование без поддержки ОС
    На старых ядрах или Windows может отсутствовать; нужна проверка.

  4. Некорректный graceful restart
    При использовании SO_REUSEPORT новый процесс может начать принимать запросы до готовности; нужно синхронизировать.

Возможные дополнительные вопросы интервьюера

  • Чем SO_REUSEPORT помогает в graceful restart?
    Можно запустить новый процесс на том же порту, пока старый завершается, без потери соединений (при использовании listener transfer или через SO_REUSEPORT).

  • Как это работает с epoll/kqueue?
    Ядро добавляет соединение в очередь приёма только одного сокета, поэтому epoll на разных файловых дескрипторах получает непересекающиеся события.

  • Какие альтернативы для балансировки?
    Внешний reverse proxy (nginx, haproxy) или SO_REUSEPORT.


Как работает fasthttp и чем он отличается от net/http?

Краткий ответ (30–60 секунд)

fasthttp — высокопроизводительная HTTP-библиотека, оптимизированная для минимального выделения памяти и аллокаций. Использует пулы объектов (буферов, request/response), избегает создания горутин на каждое соединение, позволяет zero-copy парсинг. net/http предоставляет более простую модель, но дороже по памяти и CPU.

Развёрнутый ответ

Отличия fasthttp:

  • Пул буферов: Request, Response переиспользуются, что снижает GC.
  • Worker pool: вместо go handle(conn) используется фиксированный пул воркеров, уменьшая число горутин.
  • Собственный парсер: высокооптимизированный, без выделения строк для заголовков (байтовые слайсы).
  • Несовместимость: fasthttp.Request не реализует http.Request, поэтому не подходит для стандартных middleware и gRPC (требуется адаптер).
  • Производительность: на бенчмарках выигрывает в разы, но плата — сложность и потенциальные проблемы с совместимостью.

Когда использовать: высоконагруженные роутеры, прокси, где задержка и память критичны. Для большинства приложений достаточно net/http.

Под капотом (internals)

fasthttp.Server имеет собственную горутину-акцептор, которая передаёт сырой net.Conn в пул воркеров. Каждый воркер обрабатывает несколько соединений по эвентовой модели, используя неблокирующее ожидание через epoll (кастомный), не создавая горутину на каждое соединение. Буферы для парсинга и записи берутся из sync.Pool. Отсутствует стандартная поддержка HTTP/2 (требуется внешний пакет fasthttp/http2). Заголовки представлены как []byte, а не строки.

Типичные ошибки кандидатов

  1. Бежать за fasthttp сразу
    Преждевременная оптимизация. Убедитесь, что стандартный net/http узкое место; fasthttp усложняет код, отладку, интеграцию (middleware, трассировка, OpenTelemetry).

  2. Утечка памяти из-за неправильного управления пулом
    Объекты, взятые из пула, должны возвращаться; если передавать их в горутины без возврата, пул опустошается.

  3. Неверное использование RequestCtx
    Контекст fasthttp.RequestCtx живет только во время обработки; сохранение ссылок на него вне хендлера ведёт к гонкам и use-after-free.

  4. Игнорирование таймаутов
    Параметры fasthttp.Server (ReadTimeout, WriteTimeout) работают иначе; нужно тщательно тестировать.

Возможные дополнительные вопросы интервьюера

  • Как fasthttp работает с TLS?
    Аналогично, но без автоматического HTTP/2; использует структуры из crypto/tls.

  • Можно ли совмещать fasthttp с net/http?
    Есть адаптеры fasthttpadaptor для оборачивания http.Handler в fasthttp.RequestHandler и наоборот, но с потерей производительности.

  • Какие ограничения у fasthttp?
    Нет поддержки HTTP/2 из коробки, ограниченная поддержка стриминга, несовместимость с библиотеками, ожидающими http.ResponseWriter.


Как обрабатывать multipart/form-data?

Краткий ответ (30–60 секунд)

Использовать r.ParseMultipartForm(maxMemory), затем r.MultipartForm.File и r.MultipartForm.Value. Для потоковой обработки больших файлов — r.MultipartReader(), который итеративно возвращает *multipart.Part, позволяя читать тело файла как io.Reader.

Развёрнутый ответ

Потоковый подход:

Go
reader, _ := r.MultipartReader()
for {
    part, _ := reader.NextPart()
    if part == nil { break }
    if part.FormName() == "file" {
        // читать part
        io.Copy(file, part)
    }
    part.Close()
}

maxMemory определяет, сколько данных кэшируется в памяти; остальное пишется во временные файлы. Для загрузки больших файлов лучше использовать потоковый интерфейс, чтобы не заполнять память. После закрытия частей временные файлы удаляются.

Под капотом (internals)

r.MultipartReader() парсит границу из Content-Type. Возвращает mime/multipart.Reader, который читает r.Body с учётом буфера. При достижении границы создаётся Part, содержащий заголовки части и тело (обёрнутое в io.LimitedReader для ограничения размера, если задан maxMemory). При ParseMultipartForm все файлы и поля сохраняются в памяти или временных файлах в os.TempDir.

Типичные ошибки кандидатов

  1. Передача гигантского maxMemory (например 1GB)
    Это может забить всю ОЗУ; лучше держать низкое значение (например 32MB) и обрабатывать потоково.

  2. Неправильная обработка ошибок NextPart при обрыве соединения
    NextPart может вернуть ошибку разбора; тогда нужно прекратить чтение и вернуть ошибку клиенту.

  3. Не настроен лимит размера файла
    MultipartReader не ограничивает размер одной части; нужно обернуть part в io.LimitReader с разумным пределом.

  4. Игнорирование закрытия part
    Part.Close() освобождает ресурсы; если не закрывать, могут утекать временные файлы.

Возможные дополнительные вопросы интервьюера

  • Как отправить multipart запрос из Go клиента?
    Использовать mime/multipart.Writer для создания тела, http.Post с Content-Type.

  • Что делать, если нужно принять несколько файлов в одном поле?
    Проитерироваться по MultipartForm.File["field"] — это слайс *FileHeader.

  • Как обрабатывать большие JSON-тела вместе с файлами?
    Multipart позволяет смешивать поля и файлы; значения читаются из Value map.


Как работает Server-Sent Events (SSE)?

Краткий ответ (30–60 секунд)

SSE — односторонний поток от сервера к клиенту поверх HTTP. Сервер устанавливает Content-Type: text/event-stream, использует chunked Transfer-Encoding, отправляет строки вида data: ...\n\n. Клиент использует EventSource API в браузере или в Go — чтение из resp.Body с распознаванием событий.

Развёрнутый ответ

Сервер:

Go
func sseHandler(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok { ... }
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    for {
        msg := "data: " + time.Now().String() + "\n\n"
        fmt.Fprint(w, msg)
        flusher.Flush()
        time.Sleep(1 * time.Second)
        // check context
    }
}

Клиент в Go:

Go
resp, _ := client.Do(req)
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
for {
    line, _ := reader.ReadString('\n')
    if strings.HasPrefix(line, "data:") {
        // process
    }
}

SSE прост в реализации, поддерживает авто-реконнект (браузер), идентификаторы событий (id), типы (event). Заголовок Last-Event-ID для возобновления.

Под капотом (internals)

На сервере ResponseWriter с Flusher гарантирует немедленную отправку данных. Chunked encoding позволяет отправлять куски без финального размера. На уровне TCP это обычный текст. Браузеры автоматически переподключаются при разрыве. В Go можно использовать gorilla/mux или chi для маршрутизации. Есть библиотеки, например r3labs/sse, упрощающие отправку событий.

Типичные ошибки кандидатов

  1. Не отправка первичного комментария для поддержки старых прокси
    Некоторые прокси буферизуют данные; отправка :ok\n\n помогает инициировать соединение.

  2. Забывают установить Status OK перед Flush
    Иначе браузер может считать ошибкой. WriteHeader(200) — неплохо явно задать.

  3. Блокирование цикла отправки без ухода при отключении клиента
    Нужно слушать r.Context().Done(), чтобы выйти и освободить горутину и соединение.

  4. Отсутствие keep-alive heartbeat
    Если данных долго нет, промежуточные узлы могут закрыть соединение. Нужно отправлять комментарии : heartbeat периодически.

Возможные дополнительные вопросы интервьюера

  • Как обрабатывать переподключение с последнего события?
    Клиент шлёт заголовок Last-Event-ID; сервер может хранить события и переотправлять.

  • Можно ли использовать HTTP/2 для SSE?
    Да, но браузеры не всегда разрешают; обычно через HTTP/1.1 long-lived соединение.

  • Чем SSE отличается от WebSocket?
    SSE — однонаправленный, проще; WebSocket — полнодуплексный, бинарный.


Как настроить keep-alive для клиента и сервера?

Краткий ответ (30–60 секунд)

Сервер: http.Server.IdleTimeout — время ожидания следующего запроса по keep-alive. Клиент: http.Transport поддерживает keep-alive по умолчанию, настраивается IdleConnTimeout (время простоя в пуле), MaxIdleConns, MaxIdleConnsPerHost. Также нужно читать тело ответа до конца, чтобы соединение возвращалось в пул.

Развёрнутый ответ

Конфигурация сервера:

Go
srv := &http.Server{
    Addr:        ":8080",
    IdleTimeout: 90 * time.Second, // keep-alive timeout
}

Клиент:

Go
transport := &http.Transport{
    MaxIdleConns:          100,
    MaxIdleConnsPerHost:   10,
    IdleConnTimeout:       30 * time.Second, // меньше серверного
}
client := &http.Client{Transport: transport}

Для TCP keep-alive используют net.Dialer.KeepAlive (для обнаружения мёртвых соединений). Это отдельная опция, которая передаётся через Transport.DialContext.

Go
transport.DialContext = (&net.Dialer{
    Timeout:   30 * time.Second,
    KeepAlive: 30 * time.Second, // TCP keep-alive
}).DialContext

Важно: HTTP keep-alive (переиспользование TCP) и TCP keep-alive — разные вещи. Обе дополняют друг друга.

Под капотом (internals)

Серверный IdleTimeout реализован через таймер, который сбрасывается при прочтении нового запроса. Если истекает, сервер закрывает соединение. Клиентский IdleConnTimeout контролирует нахождение persistConn в пуле; при изъятии таймер останавливается. DisableKeepAlives = true отключает HTTP keep-alive для клиента.

Типичные ошибки кандидатов

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

  2. Не настроен TCP keep-alive, полагаются только на HTTP
    Если HTTP keep-alive держит соединение открытым, но по сети нет трафика долго, NAT/фаерволы закроют; TCP keep-alive решает эту проблему.

  3. Не учитывают, что HTTP/2 имеет свои keep-alive пинги
    Для HTTP/2 keep-alive через PING фреймы настраиваются через http2.Transport (поле PingTimeout, PingResponse).

  4. Отключение keep-alive без понимания последствий
    Каждый новый запрос создаёт новое TCP-соединение, увеличивая latency и потребление ресурсов.

Возможные дополнительные вопросы интервьюера

  • Как проверить, что keep-alive работает?
    Использовать netstat для просмотра состояний TCP, или httptrace колбэки GotConn.Reused.

  • Какие заголовки управляют keep-alive?
    Connection: keep-alive (устарел), Keep-Alive: timeout=5, max=100 — но в HTTP/1.1 игнорируются, сервер сам решает.

  • Можно ли отключить keep-alive на уровне отдельного ответа?
    Да, w.Header().Set("Connection", "close").


Как работает net.LookupHost для разрешения DNS?

Краткий ответ (30–60 секунд)

net.LookupHost преобразует доменное имя в список IP-адресов, используя системный резолвер (C библиотека или встроенный Go-резолвер). В Go по умолчанию используется встроенный резолвер, который делает параллельные запросы к настроенным DNS-серверам (из /etc/resolv.conf). Возвращает слайс строк IP.

Развёрнутый ответ

Пример:

Go
ips, err := net.LookupHost("example.com")

По умолчанию Go резолвер использует нативный Go DNS-клиент (net.Resolver), который считывает настройки из resolv.conf. Он выполняет запросы A и AAAA параллельно, реализует happy eyeballs. Можно кастомизировать через Resolver.Dial или использовать системный (CGO) через env GODEBUG=netdns=cgo.

Для низкоуровневого контроля можно использовать net.Resolver.LookupIPAddr(ctx, host).

Под капотом (internals)

Встроенный резолвер в пакете net реализован в dnsclient_unix.go и dnsclient.go. Он отправляет UDP-запросы (и TCP при необходимости) к серверам из resolv.conf. Обрабатывает таймауты, повторы. Кэширование DNS происходит на уровне ОС, не в Go (если не используется CGO). В Go 1.22+ улучшена обработка ошибок и таймаутов. При отмене контекста запрос прерывается.

Типичные ошибки кандидатов

  1. Игнорирование контекста
    LookupHost без контекста может зависнуть на неопределённое время; нужно использовать net.DefaultResolver.LookupHost(ctx, host) с таймаутом.

  2. Предположение, что результат всегда содержит один IP
    Возвращается слайс; клиентский код должен обрабатывать все адреса или использовать Dialer.Resolver для проб.

  3. Не проверка ошибки на временный сбой DNS
    DNS может вернуть временную ошибку; стоит иметь retry или fallback.

  4. Использование net.LookupHost для проверки доступности хоста
    Это некорректно; лучше делать net.Dial.

Возможные дополнительные вопросы интервьюера

  • Как настроить кастомный DNS-сервер для резолвера?
    Использовать net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { return dnsDialer.DialContext(ctx, network, "8.8.8.8:53") }}.

  • Чем отличается LookupIPAddr от LookupHost?
    LookupIPAddr возвращает []net.IPAddr, предоставляя больше информации (зона), LookupHost — строки.

  • Как работает кэширование DNS?
    В Go нет встроенного кэша, но ОС кэширует, что может приводить к задержкам обновления. Для кастомного кэширования используют библиотеки.


Как работает net/http/httputil.ReverseProxy?

Краткий ответ (30–60 секунд)

ReverseProxy — стандартный реверс-прокси в Go. Он принимает входящие запросы, изменяет URL, копирует заголовки, выполняет запрос к бэкенду, копирует ответ обратно клиенту. Поддерживает WebSocket (через Hijack), модификацию запроса/ответа, балансировку.

Развёрнутый ответ

Пример базового прокси:

Go
proxy := httputil.NewSingleHostReverseProxy(targetURL)
log.Fatal(http.ListenAndServe(":8888", proxy))

Можно переопределять Director для модификации запроса, ModifyResponse для изменения ответа, ErrorHandler. Для WebSocket проксирование поддерживается автоматически при наличии заголовка Upgrade.

Также можно реализовать балансировку между несколькими бэкендами, изменяя Director.

Под капотом (internals)

ReverseProxy.ServeHTTP:

  1. Клонирует входящий запрос, создаёт outbound запрос.
  2. Вызывает Director(req) для настройки outbound запроса (URL, заголовки).
  3. Выполняет transport.RoundTrip(outReq).
  4. Если ответ получен, копирует заголовки ответа в w, затем io.Copy(w, resp.Body).
  5. Обрабатывает Upgrade (Hijack): для WebSocket захватывает нижележащее соединение и тунельирует.

Поддерживает FlushInterval для стриминга. BufferPool для управления буферами копирования.

Типичные ошибки кандидатов

  1. Не корректная обработка X-Forwarded-For
    Прокси должен добавлять IP клиента в X-Forwarded-For, иначе бэкенд не имеет информации. NewSingleHostReverseProxy не делает этого автоматически; нужно в Director или использовать httputil.ReverseProxy и настраивать.

  2. Забывают про Host заголовок
    Director должен установить outReq.Host = target.Host, иначе бэкенд может получить неправильный Host.

  3. Копирование тела без учета Content-Length/Chunked
    Тело копируется через io.Copy, что нормально, но если ответ с Content-Encoding: gzip, может возникнуть двойное сжатие, если не правильно настроен ModifyResponse. Также грейсфул завершение (ожидание конца копирования) важно.

  4. Не настроен таймаут на транспорт
    По умолчанию ReverseProxy использует http.DefaultTransport. Лучше задать кастомный транспорт с таймаутами и ограничениями.

Возможные дополнительные вопросы интервьюера

  • Как реализовать circuit breaker?
    В Transport обернуть RoundTripper или использовать библиотеку; при ошибках возвращать fallback ответ.

  • Как проксировать WebSocket?
    ReverseProxy автоматически обрабатывает Upgrade, но нужно убедиться, что Director не удаляет заголовки Connection и Upgrade.

  • Как изменить путь при проксировании?
    В Director модифицировать outReq.URL.Path.


Что такое DrainBody и почему важно вычитывать тело ответа до конца?

Краткий ответ (30–60 секунд)

DrainBody — неофициальный термин для полного чтения и закрытия тела HTTP-ответа. При использовании keep-alive, если не прочитать тело до конца и не закрыть его, TCP-соединение не может быть переиспользовано. Это приводит к утечке соединений и истощению ресурсов.

Развёрнутый ответ

В Go для клиента: resp.Body.Close() просто закрывает reader, но если остались непрочитанные данные, соединение не вернётся в пул. Нужно гарантированно прочитать остаток и закрыть. Лучшая практика:

Go
defer func() {
    io.Copy(io.Discard, resp.Body)
    resp.Body.Close()
}()

Это особенно важно при редиректах, когда http.Client не вычитывает тела у промежуточных ответов. Или можно использовать defer resp.Body.Close() после чтения, но перед этим обязательно io.Copy(io.Discard, ...).

Под капотом (internals)

В Transport.persistConn возвращение в idleConn происходит при закрытии тела, если не было ошибок и нет незавершённого чтения. Если Body.Close вызван, но в bufio.Reader остались данные, соединение не считается чистым и не кешируется. Это предотвращает случайное чтение следующего ответа с мусором.

Типичные ошибки кандидатов

  1. Закрытие тела без вычитки
    Думают, что resp.Body.Close() достаточно. Многие делают defer resp.Body.Close(), не читая тело, и удивляются, что соединения не переиспользуются.

  2. Паника при закрытии тела, если ответ nil
    При err != nil resp может быть nil. Проверять if resp != nil && resp.Body != nil.

  3. Игнорирование в случае редиректов
    http.Client при редиректах может не вычитывать тело; custom CheckRedirect может потребоваться.

  4. Использование io.LimitedReader без вычитки остатка
    Если ограничили чтение, всё равно нужно вычитать остаток для keep-alive.

Возможные дополнительные вопросы интервьюера

  • Как принудительно закрыть соединение без вычитки?
    Установить req.Close = true или Connection: close.

  • Как проверить, что соединение вернулось в пул?
    Использовать httptrace.PutIdleConn.

  • Отличается ли поведение для HTTP/2?
    В HTTP/2 закрытие стрима выполняется после полного прочтения тела, но само TCP-соединение не закрывается; не вычитанное тело может привести к утечке памяти в потоке.


Как работает http.RoundTripper и как его можно мокать?

Краткий ответ (30–60 секунд)

http.RoundTripper — интерфейс, определяющий RoundTrip(*Request) (*Response, error). Это ядро клиентского транспорта. Мокать можно, создав свою реализацию, возвращающую предопределённый ответ, или обернув существующий транспорт для перехвата запросов (для тестов, логирования, добавления заголовков).

Развёрнутый ответ

Интерфейс:

Go
type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

http.Client вызывает его. По умолчанию используется http.DefaultTransport. Мок для тестов:

Go
type mockTransport struct {
    response *http.Response
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    return m.response, nil
}
client := &http.Client{Transport: &mockTransport{resp}}

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

Под капотом (internals)

В стандартном Transport RoundTrip сложный: обрабатывает схему URL, прокси, выбирает или создаёт persistConn, записывает запрос, читает ответ, обрабатывает редиректы (но редиректы в клиенте выше). RoundTripper позволяет цепочку: несколько обёрток, где каждая вызывает next.RoundTrip.

Типичные ошибки кандидатов

  1. Некорректное поведение мока: не закрывают тело ответа
    Мока ответ должен содержать Body, которое при закрытии ничего не делает, но тесты могут падать, если тело nil.

  2. Модификация Request после передачи в RoundTrip
    Нельзя изменять запрос после вызова, это нарушает контракт.

  3. Забывают про контекст и его отмену
    Если мока долгая, должна реагировать на req.Context().Done(), чтобы не висеть.

  4. Использование мока как замена функционального теста
    Полностью мокая транспорт, теряется интеграционное покрытие; комбинировать с интеграционными тестами.

Возможные дополнительные вопросы интервьюера

  • Как добавить OAuth2 токен ко всем запросам?
    Создать кастомный RoundTripper, оборачивающий транспорт и добавляющий заголовок в RoundTrip.

  • Как написать RoundTripper, который кэширует ответы?
    Проверять метод, кэш-ключ, при попадании возвращать сохранённый ответ.

  • Чем отличается RoundTripper от Handler?
    RoundTripper для клиента, делает запрос и получает ответ. Handler для сервера, принимает запрос и формирует ответ.


Как работать с x-www-form-urlencoded данными?

Краткий ответ (30–60 секунд)

Это format запроса с Content-Type: application/x-www-form-urlencoded, где данные кодируются как key=value&... с URL-кодированием. В Go для парсинга используется r.ParseForm(), значения доступны через r.Form. Для клиента — url.Values с методом Encode().

Развёрнутый ответ

На сервере:

Go
func handler(w http.ResponseWriter, r *http.Request) {
    r.ParseForm()
    name := r.Form.Get("name")
    // или r.PostForm для POST
}

Клиент:

Go
form := url.Values{}
form.Set("name", "Alice")
form.Set("age", "30")
body := form.Encode()
resp, _ := http.Post("http://example.com", "application/x-www-form-urlencoded", strings.NewReader(body))

ParseForm также обрабатывает query-параметры. Важно: для загрузки больших данных не рекомендуется из-за низкой эффективности бинарных данных.

Под капотом (internals)

ParseForm вызывает r.Body чтение и парсит URL-кодированные данные в url.Values. Используется url.ParseQuery. Максимальный размер тела для разбора задаётся MaxFormSize (по умолчанию 10MB). r.Form включает и URL-параметры, и тело для POST/PUT. При конфликте параметр из query идёт первым.

Типичные ошибки кандидатов

  1. Забывают вызвать ParseForm перед доступом к r.Form
    Иначе r.Form пуст. ParseForm вызывается обычно в middleware.

  2. Перепутать r.Form и r.PostForm
    r.Form содержит и URL-параметры, r.PostForm только тело POST/PUT.

  3. Отправка бинарных данных как form-urlencoded
    Неэффективно, предназначено только для текстовых данных. Для файлов — multipart.

  4. Не проверка ошибок ParseForm
    Может вернуть ошибку при превышении размера или неправильном формате; нужно обрабатывать.

Возможные дополнительные вопросы интервьюера

  • Как закодировать вложенные структуры в form-urlencoded?
    Обычно не поддерживается; можно имитировать через квадратные скобки key[sub]=value, но нужно кастомное кодирование.

  • Как отправить массивы значений?
    key=val1&key=val2 — url.Values.Add("key", "val1"); Add("key", "val2").

  • В чём отличие от multipart/form-data?
    multipart для загрузки файлов, form-urlencoded для простых текстовых форм.


Как работает h2c (HTTP/2 без TLS)?

Краткий ответ (30–60 секунд)

h2c — HTTP/2 поверх незашифрованного TCP. Клиент и сервер договариваются через механизм Upgrade (HTTP/1.1 Upgrade: h2c). В Go используется x/net/http2/h2c или golang.org/x/net/http2 с ручной настройкой.

Развёрнутый ответ

Сервер:

Go
import "golang.org/x/net/http2"
import "golang.org/x/net/http2/h2c"

s := &http.Server{Addr: ":8080", Handler: mux}
http2.ConfigureServer(s, &http2.Server{})
log.Fatal(s.ListenAndServe())
// Обёртка с h2c
http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))

Клиент:

Go
client := &http.Client{
    Transport: &http2.Transport{
        AllowHTTP: true,
        DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
            return net.Dial(network, addr)
        },
    },
}

Или использовать h2c.NewClient. Используется для внутренних сервисов, где не нужен TLS overhead.

Под капотом (internals)

Без TLS, h2c.NewHandler оборачивает стандартный HTTP/1.1 сервер и перехватывает запросы с Upgrade: h2c, HTTP2-Settings. При успешном обновлении соединение передаётся в http2.Server. Иначе продолжается HTTP/1.1. На транспортном уровне http2.Transport с AllowHTTP: true и DialTLS, который просто создаёт TCP-соединение, инициирует Upgrade. Flow control и другие механизмы работают аналогично TLS версии.

Типичные ошибки кандидатов

  1. Думать, что h2c работает "из коробки" без настройки
    Стандартный net/http не поддерживает h2c; нужен явный вызов h2c.NewHandler или http2.Transport с AllowHTTP.

  2. Использовать h2c для внешних клиентов
    Многие сети блокируют не TLS трафик; для публичного API используйте HTTPS.

  3. Забывают настроить AllowHTTP и кастомный DialTLS
    Иначе транспорт пытается установить TLS соединение, и запрос падает.

  4. Неправильный порядок обновления
    Если сервер не возвращает 101 Switching Protocols, клиент остаётся на HTTP/1.1; важно логировать для отладки.

Возможные дополнительные вопросы интервьюера

  • Зачем использовать h2c?
    Для backend-коммуникаций, когда TLS избыточен и нужна производительность; мультиплексирование.

  • Как включить h2c на gRPC сервере?
    grpc.NewServer с grpc.Insecure() и использование h2c транспорта.

  • Какие ограничения у h2c?
    Отсутствие шифрования, невозможность использования в браузере (браузеры не поддерживают), возможны проблемы с прокси.


Как реализовать "long polling" с помощью Go?

Краткий ответ (30–60 секунд)

Long polling — клиент отправляет HTTP-запрос, сервер удерживает соединение открытым до появления данных или таймаута, затем отвечает. Клиент сразу же отправляет новый запрос. В Go сервер просто блокирует обработчик (ожидая через канал или контекст) и пишет ответ, когда готов. Использовать таймауты, чтобы не зависать навечно.

Развёрнутый ответ

Серверная реализация:

Go
func longPollHandler(w http.ResponseWriter, r *http.Request) {
    // ждем данные не дольше 30 сек
    ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
    defer cancel()
    select {
    case msg := <-messageChannel:
        fmt.Fprint(w, msg)
    case <-ctx.Done():
        http.Error(w, "timeout", 304)
    }
}

Клиент в цикле делает запросы; при получении ответа или таймаута — снова запрашивает.

Под капотом (internals)

Сервер блокирует горутину на чтение канала или контекста. Пока соединение открыто, TCP keep-alive помогает не разрываться промежуточными устройствами. Нужно настроить WriteTimeout сервера достаточно большим, чтобы покрыть максимальный интервал опроса. Так как горутина дешева, можно обслуживать тысячи long-polling соединений.

Типичные ошибки кандидатов

  1. Отсутствие таймаута ожидания
    Соединение может висеть бесконечно, занимая сокет и горутину. Нужно обязательно ограничивать время ожидания.

  2. Блокировка горутины без учета закрытия соединения клиентом
    Использовать r.Context().Done() или w.(http.CloseNotifier).CloseNotify() (устарело) для выхода при обрыве.

  3. Неправильный выбор между long polling и WebSocket/SSE
    Использовать long polling для редких событий, где WebSocket избыточен; но для частых обновлений он создаёт лишние запросы.

  4. Не установлены таймауты на стороне клиента
    Клиент должен иметь timeout больше серверного, чтобы обработать ошибку и переподключиться.

Возможные дополнительные вопросы интервьюера

  • Как обрабатывать несколько клиентов, ждущих одно событие?
    Использовать брокер (канал, pub/sub), который одновременно уведомляет всех подписчиков.

  • Как отличить long polling от обычного polling?
    Обычный — клиент делает запросы с интервалом, long — сервер держит до появления данных.

  • Как реализовать long polling с балансировщиком нагрузки?
    Балансировщик должен поддерживать длинные соединения и не разрывать их по idle таймауту.


Как тестировать HTTP хендлеры с помощью httptest?

Краткий ответ (30–60 секунд)

Пакет net/http/httptest предоставляет httptest.NewServer (для интеграционных тестов) и httptest.NewRecorder (для unit-тестов обработчиков). NewRecorder реализует http.ResponseWriter, позволяя проверить статус, заголовки и тело без реального сервера.

Развёрнутый ответ

Unit-test хендлера:

Go
func TestHandler(t *testing.T) {
    handler := http.HandlerFunc(myHandler)
    req := httptest.NewRequest("GET", "/path?key=val", nil)
    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    resp := w.Result()
    body, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != 200 {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
}

Интеграционный тест с реальным TCP:

Go
ts := httptest.NewServer(handler)
defer ts.Close()
resp, err := http.Get(ts.URL + "/path")

httptest.Server автоматически выбирает свободный порт.

Под капотом (internals)

ResponseRecorder записывает статус, заголовки, тело в bytes.Buffer. Реализует http.ResponseWriter и дополнительные интерфейсы (Flusher, Push, Hijacker — заглушка). NewRequest создаёт http.Request с контекстом и возможностью установки заголовков. Сервер запускает реальный TCP-сервер на локалхосте.

Типичные ошибки кандидатов

  1. Не читают тело ответа в recorder перед проверкой
    После w.Result() тело доступно, но нужно закрыть, чтобы освободить ресурсы.

  2. Ошибочное использование httptest.NewServer для каждого теста (медленно)
    Для юнит-тестов хендлеров достаточно NewRecorder; NewServer для интеграционных.

  3. Забывают проверить заголовки после вызова Write
    В recorder можно проверять заголовки в любое время, но в реальном ответе после WriteHeader заголовки фиксируются.

  4. Не тестируют крайние случаи: ошибки при записи
    ResponseRecorder не эмулирует ошибку Write. Для этого нужны моки.

Возможные дополнительные вопросы интервьюера

  • Как тестировать middleware?
    Создать NewRecorder, запрос, обернуть тестовый обработчик в middleware и выполнить ServeHTTP.

  • Как проверить, что хендлер установил определенный кук?
    resp.Result().Cookies() или w.Header().Get("Set-Cookie").

  • Можно ли симулировать разрыв соединения?
    Через NewRecorder нет; нужно создавать кастомный ResponseWriter, который на Write возвращает ошибку, либо использовать httptest.Server и закрывать клиента.


Как ограничить количество входящих соединений на HTTP сервере?

Краткий ответ (30–60 секунд)

Использовать семафор (буферизованный канал) в net.Listener обёртке, ограничивающей вызов Accept, либо в middleware, который держит счётчик активных запросов. Также можно настраивать системные лимиты (backlog, ulimit). В Go 1.22+ есть http.Server.MaxConnsPerIP, MaxConns. Но они не экспортированы; необходимо использовать кастомный листенер или netutil.

Развёрнутый ответ

Кастомный листенер с ограничением:

Go
type limitListener struct {
    net.Listener
    sem chan struct{}
}
func (l *limitListener) Accept() (net.Conn, error) {
    l.sem <- struct{}{}
    return l.Listener.Accept()
}
func (l *limitListener) release() {
    <-l.sem
}
// при использовании
ln, _ := net.Listen("tcp", ":8080")
limited := &limitListener{Listener: ln, sem: make(chan struct{}, maxConns)}
for {
    conn, _ := limited.Accept()
    go func() {
        defer limited.release()
        handle(conn)
    }()
}

Другой способ: middleware, который с помощью счётчика (atomic) лимитирует запросы, возвращая 503 при превышении. Это не предотвращает открытие TCP-соединения, но ограничивает нагрузку на обработчики.

Под капотом (internals)

В http.Server внутренняя структура отслеживает количество активных соединений, но публичных методов ограничения нет до версии 1.22 (где есть MaxConns? На самом деле в Go 1.22 добавили http.Server.MaxConns и MaxConnsPerIP? Проверим: в экспериментах http.Server имеет поля MaxConns и MaxConnsPerIP в Go 1.22 — да, добавлены. Они позволяют ограничить количество соединений на уровне сервера. Использование: srv.MaxConns = 1000. Это предпочтительный способ.

Типичные ошибки кандидатов

  1. Отсутствие ограничения
    Без лимита сервер может принять огромное количество соединений, что приведёт к исчерпанию файловых дескрипторов и памяти.

  2. Использование middleware для ограничения соединений, а не запросов
    Middleware ограничивает количество одновременно обрабатываемых запросов, но TCP-соединения всё равно открываются и потребляют ресурсы. Лучше ограничивать на уровне листенера или использовать MaxConns.

  3. Забывают про keep-alive при подсчёте соединений
    Каждое keep-alive соединение — это открытый сокет; лимит должен учитывать их.

  4. Неправильная конфигурация MaxConnsPerIP
    Может блокировать легитимных клиентов за NAT, если значение слишком мало.

Возможные дополнительные вопросы интервьюера

  • Как работают http.Server.MaxConns и MaxConnsPerIP?
    Они ограничивают количество одновременных открытых соединений. При достижении лимита новые соединения не принимаются (Accept не вызывается). Добавлены в Go 1.22.

  • Как сбрасывать лишние соединения с статусом 503?
    Нельзя на этапе Accept; можно закрывать соединение сразу после Accept без чтения.

  • Какие ещё методы ограничения?
    Использовать golang.org/x/net/netutil с LimitListener.