gqlgen_fasthttp

GraphQL Subscriptions: gqlgen + fasthttp

Проблема

gqlgen транспорты для подписок (WebSocket, SSE) построены на net/http. fasthttp использует другую модель запросов/ответов — API несовместимы на фундаментальном уровне.

Варианты решения

1. Dual Server + Caddy reverse proxy (рекомендуется)

Два сервера в одном процессе: fasthttp для queries/mutations, net/http для subscriptions. Caddy роутит по типу запроса.

Плюсы:

  • Производительность fasthttp для 99% трафика (queries/mutations)
  • Стандартные gqlgen подписки работают без модификаций
  • Один endpoint для клиентов
  • Caddy уже используется в проекте

Минусы:

  • Два сервера в одном процессе
  • Нужно шарить ExecutableSchema между ними

Пример:

// internal/server/subscriptions.go
func StartSubscriptionServer(schema graphql.ExecutableSchema) {
    srv := handler.New(schema)
    srv.AddTransport(transport.Websocket{
        KeepAlivePingInterval: 10 * time.Second,
    })
    http.Handle("/graphql", srv)
    http.ListenAndServe(":8082", nil)
}

// cmd/server/main.go
func main() {
    go StartFasthttpServer()      // :8081
    go StartSubscriptionServer()  // :8082
    select {}
}

Caddyfile:

handle /graphql {
    @websocket {
        header Connection *Upgrade*
        header Upgrade websocket
    }
    reverse_proxy @websocket localhost:8082
    reverse_proxy localhost:8081
}

2. SSE вместо WebSocket

SSE проще WebSocket и лучше подходит для GraphQL подписок (нужен только server→client поток).

Плюсы:

  • Проще протокол
  • Firewall-friendly (обычный HTTP)
  • Можно тестировать через curl
  • HTTP/2 даёт ~100 конкурентных подписок

Минусы:

  • Требует SSE-совместимый клиент (graphql-sse)
  • GraphQL Playground не поддерживает SSE
  • HTTP/1.1 — лимит 6 соединений на браузер
  • Всё равно требует net/http (та же проблема с fasthttp)

Конфигурация gqlgen:

srv.AddTransport(transport.SSE{
    KeepAlivePingInterval: 10 * time.Second,
})

Тест через curl:

curl -N --request POST --url http://localhost:8082/graphql \
  --data '{"query":"subscription { currentTime { unixTime } }"}' \
  -H "accept: text/event-stream" -H 'content-type: application/json'

3. Полный переход на net/http

Заменить fasthttp на стандартный net/http.

Плюсы:

  • Всё работает из коробки
  • HTTP/2 поддержка
  • Больше экосистема и middleware

Минусы:

  • Потеря производительности fasthttp (30-70% в синтетических тестах)

Когда выбирать: если подписки критичны, а выигрыш fasthttp не оправдывает сложности.

4. Не рекомендуется

Вариант Почему нет
fastgql (arsmn/fastgql) Не поддерживается с 2021, нет документации по подпискам
Кастомный адаптер Высокая сложность, баги, поддержка
Два endpoint для клиентов Плохой DX, сложная конфигурация клиента

WebSocket vs SSE

Аспект WebSocket SSE
Сложность Высокая (upgrade, свой протокол) Низкая (обычный HTTP)
Firewall Могут блокировать upgrade Без проблем
HTTP/2 Нет Да
Тестирование Нужен WS клиент curl работает
Для GraphQL Overkill (bidirectional не нужен) Идеально (server→client)
Поддержка клиентов Apollo, urql, все graphql-sse, растёт
gqlgen Встроенный, зрелый Встроенный

Индустрия движется к SSE для GraphQL подписок — WebSocket избыточен когда нужен только server→client поток.

Рекомендация

Вариант 1 (Dual Server + Caddy) с SSE транспортом:

  • fasthttp на :8081 — queries/mutations (основной трафик)
  • net/http на :8082 — subscriptions через SSE
  • Caddy роутит по заголовку Accept: text/event-stream
  • Один /graphql endpoint для клиентов

Это даёт: производительность fasthttp + простоту SSE + стандартный gqlgen без хаков.

Решение: SSE через fasthttpadaptor (реализовано)

Оказалось, что fasthttpadaptor.NewFastHTTPHandler() реализует http.Flusher — gqlgen SSE транспорт работает напрямую через fasthttp без второго сервера.

Три изменения, каждое необходимо:

1. SSE транспорт первым в списке

internal/graph/handler.go — порядок важен, SSE и POST оба принимают POST-запросы. Если POST идёт первым — он перехватывает SSE запрос.

srv.AddTransport(transport.SSE{})       // первым!
srv.AddTransport(transport.Options{})
srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})

2. Обход TimeoutHandler

cmd/server/main.go — SSE соединение живёт бесконечно, fasthttp.TimeoutHandler убивает через 60 секунд.

timeoutHandler := fasthttp.TimeoutHandler(handler, handlerTimeout, "timeout")

s := &fasthttp.Server{
    Handler: func(ctx *fasthttp.RequestCtx) {
        if strings.Contains(string(ctx.Request.Header.Peek("Accept")), "text/event-stream") {
            handler(ctx) // напрямую, без таймаута
            return
        }
        timeoutHandler(ctx)
    },
}

3. Обход CompressHandler

cmd/server/main.gofasthttp.CompressHandler буферизирует весь ответ. SSE требует streaming.

case strings.Contains(string(ctx.Request.Header.Peek("Accept")), "text/event-stream"):
    graphqlHandler(ctx)           // без сжатия
default:
    compressedGraphqlHandler(ctx) // обычные запросы со сжатием

Проверка

curl -N --request POST --url http://localhost:8081/graphql \
  --data '{"query":"subscription { currentTime }"}' \
  -H "accept: text/event-stream" -H "content-type: application/json"

Результат — events приходят по одному каждую секунду:

event: next
data: {"data":{"currentTime":"2026-02-08 06:51:20"}}

event: next
data: {"data":{"currentTime":"2026-02-08 06:51:21"}}

Ссылки