Pular para o conteúdo

Trilha de Go · parte 53 de 65

WebSocket em Go: handshake, quadros e broadcast

Como o WebSocket mantém uma conexão de duas vias sobre HTTP, com handshake, quadros, ping e um servidor Go com hub de broadcast.

intermediárioPublicada em · 33 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine um telefone que ninguém desligou. Você liga, alguém atende, e a partir daí qualquer um dos dois fala quando quiser, sem avisar antes. Esse é o : uma conexão que começa como uma requisição HTTP comum e, depois que o servidor aceita, vira um canal por onde cliente e servidor trocam mensagens nos dois sentidos. O HTTP funciona como correspondência. Você escreve, posta, espera a resposta, e a conversa termina ali.

O caso típico é um chat, em que as mensagens chegam de qualquer lado e nenhuma delas foi pedida. Jogos multiplayer, quadros colaborativos e feeds de cotação seguem o mesmo padrão. Esta aula mostra o que acontece por baixo, do pedido de ao fechamento, e depois monta um servidor em Go que aguenta o mundo real: cliente lento, queda de rede, mensagem grande demais e um desligamento que não derruba ninguém no meio de uma frase.

O código foi executado. A biblioteca é a github.com/coder/websocket na versão 1.8.15, publicada em 15/06/2026. Os testes, a captura do e o experimento com WriteTimeout foram feitos em 03/11/2026 com Go 1.27.2. O que não foi possível verificar está na seção 12.

mapa da aula

ParteSeçõesO que você vai aprender
1. O problema1 e 2Por que o HTTP não basta e o que o handshake de upgrade faz
2. O protocolo3 e 4Quadros, , , fechamento e códigos de status
3. A decisão5Quando o WebSocket vale a pena e quando SSE ou polling resolvem
4. A implementação6 e 7A biblioteca, o eco mínimo, a versão de produção, os testes e o
5. Produção8 e 9Limites, , reconexão, autenticação, encerramento e erros comuns
6. Prática10 a 13Exercícios, glossário, o que não foi confirmado e fontes

A aula fica na fase 7 da trilha, de APIs, microsserviços e mensageria, ao lado de SSE e EventSource e gRPC. O número exato depende da reorganização da trilha, que hoje ainda lista WebSocket como 10.4. Os pré-requisitos são Servidor HTTP Nativo Moderno, para o servidor e os handlers, e Goroutine, para entender por que leitura e escrita ficam em goroutines separadas. A comparação com a aula irmã está na seção 5.

1. o problema: http só responde quando perguntado

O HTTP foi pensado para documentos. O cliente pede e o servidor responde, e quem começa a conversa é sempre o cliente. Se o servidor tiver uma novidade às 14h03, não há conexão aberta por onde mandá-la.

A saída mais antiga é o polling, em que o cliente pergunta a cada poucos segundos se há novidade. Um painel que pergunta a cada 2 segundos faz 1800 requisições por hora, e a maioria volta vazia. O long polling segura a resposta até haver um evento, o que reduz a espera, mas cada evento continua custando uma requisição completa.

O SSE usa uma única resposta que nunca termina, e o servidor escreve eventos nela. Serve muito bem quando só o servidor fala. A dificuldade aparece quando o cliente também precisa falar sem parar, porque cada mensagem dele volta a ser uma requisição nova. Um chat precisa das duas direções com baixa latência.

O WebSocket é um protocolo separado, que começa com um pedido HTTP pedindo a troca. Se o servidor aceita, a conexão TCP continua aberta e passa a falar um protocolo próprio, em que cada lado escreve quando quer. O preço é que o seu código assume tarefas que o HTTP fazia sozinho: detectar cliente morto, limitar o tamanho das mensagens, reconectar e decidir o que fazer com o que se perdeu durante uma queda.

2. handshake: como uma conexão http vira websocket

O handshake ainda usa HTTP. O cliente envia um GET com cabeçalhos especiais:

GET /ws HTTP/1.1
Host: servidor.exemplo
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.exemplo
Cookie: sessao=...

Upgrade e Connection pedem a troca de protocolo. Sec-WebSocket-Version: 13 é a versão da RFC 6455, a única em uso. Sec-WebSocket-Key leva 16 bytes aleatórios em base64. O servidor concatena essa chave com um GUID fixo da própria RFC, calcula o SHA-1 e devolve o resultado em base64 em Sec-WebSocket-Accept. O cálculo serve para provar que o servidor entende o protocolo. Ele não protege nada, porque qualquer um que conheça a RFC consegue refazê-lo.

O Origin diz de qual página veio o pedido, e o Cookie vai junto porque o navegador o anexa automaticamente. Os dois pesam na segurança, e a seção 8.4 volta a eles.

Esta é a resposta real do servidor da aula, para a chave de exemplo da RFC:

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-Websocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Upgrade: websocket

O valor s3pPLMBiTxaQ9kYGzzhZRbK+xOo= é o que a RFC lista como esperado para essa chave. A partir do 101, os bytes que passam pelo socket já são quadros do WebSocket.

O handshake também recusa. A aula recusa de três formas, e cada uma vem de um lugar diferente. Sem o cookie de sessão, o servidor responde 401 antes do Accept, então um cliente não autenticado nem chega a ter conexão WebSocket. Um GET sem os cabeçalhos de upgrade recebe 426, respondido pela biblioteca. Uma origem fora da lista recebe 403, também da biblioteca, para que uma página de outro site não consiga abrir conexão com o servidor usando os cookies de quem a visita.

Linha do tempo do handshake: o GET /ws com Upgrade recebe 101 com sessão e origem válidas, ou 401 e 403 quando a recusa acontece antes da conexão.
figura 1 · Handshake WebSocket: do GET ao 101

A figura mostra a decisão do servidor com duas saídas. No caminho feliz, o 101 abre o canal, e os quadros de texto passam nos dois sentidos. Na recusa, a resposta é 401 ou 403, e a conexão nunca existe.

3. depois do handshake: quadros

A mensagem não viaja solta. Ela vai dentro de um ou mais quadros (frames), e cada tem um cabeçalho de bits com formato fixo.

O primeiro byte tem o bit FIN, que marca o último quadro de uma mensagem, três bits RSV, zerados quando nenhuma extensão foi negociada, e quatro bits de , que dizem o tipo do quadro. O segundo byte tem o bit MASK e sete bits de tamanho. Se o tamanho passa de 125, os bytes seguintes trazem o tamanho real, em 16 ou 64 bits. Depois vem a chave de máscara, de quatro bytes, quando MASK está ligado, e por fim a carga.

OpcodeTipoUso
0x0continuaçãoPedaço seguinte de uma mensagem fragmentada
0x1textoDados em UTF-8
0x2binárioBytes livres
0x8closeInício ou resposta do fechamento
0x9pingPergunta de vida
0xApongResposta ao ping

Quadros de controle, como ping, pong e close, têm carga de até 125 bytes e não podem ser fragmentados. Eles podem chegar no meio de uma mensagem grande, e por isso um ping não espera a transmissão de um arquivo inteiro. Um pong precisa repetir a carga do ping que responde.

A máscara é a regra mais estranha. Todo quadro do cliente precisa vir mascarado, e o servidor não mascara o que envia. A RFC explica o motivo na seção 10.3: proxies antigos podiam ser enganados por um script que controlava os bytes na rede e fingia uma requisição HTTP dentro do WebSocket. Com uma chave nova para cada quadro, o script não escolhe os bytes que chegam ao proxy. A biblioteca desta aula rejeita um quadro do cliente sem máscara com erro de protocolo.

A figura reproduz, byte a byte, dois exemplos da seção 5.7 da RFC: um texto do cliente mascarado com a palavra “Hello”, e um ping do servidor com a mesma carga, sem máscara. Foi feita a partir do texto da RFC, e não de tráfego real.

Dois quadros WebSocket byte a byte: texto mascarado do cliente, com chave de quatro bytes, e ping do servidor sem máscara, com cada bit de cabeçalho visível.
figura 2 · Quadros WebSocket: texto mascarado e ping

No primeiro quadro, o byte 0x81 marca FIN e opcode 1, texto. O byte seguinte liga a máscara e informa tamanho 5. A carga só volta a ser “Hello” depois do XOR com a chave. No segundo, o opcode 9 marca o ping, e a máscara fica zerada.

4. ciclo de vida e fechamento

Uma conexão passa por abertura, troca de mensagens e controle, fechamento e, se algo der errado, queda. As três primeiras seguem o protocolo. A queda não tem quadro nenhum, e é por isso que ela exige cuidado.

Durante a troca, qualquer lado pode mandar ping, e o outro precisa responder com pong. Esse é o único sinal de que o par ainda existe, porque cabo puxado e celular sem sinal não avisam ninguém.

O fechamento é um aperto de mão de duas vias. Quem quer encerrar envia um close, cuja carga começa com um de dois bytes e pode trazer um motivo em UTF-8 de até 123 bytes. O outro lado responde com outro close, e só então o TCP fecha. A biblioteca responde o close do par sozinha, dentro da leitura. Já o Close do seu código escreve o quadro com prazo de 5 segundos e espera até 5 segundos pelo close de volta.

CódigoNomeQuando aparece
1000Normal closureFechamento combinado
1001Going awayServidor ou navegador saindo, como em um deploy
1002Protocol errorQuadro que viola o protocolo
1003Unsupported dataTipo de dado que o lado não aceita
1008Policy violationRegra da aplicação violada, como cliente lento
1009Message too bigMensagem acima do limite configurado
1011Internal errorErro do servidor
1005 e 1006ReservadosNão podem ser enviados; 1006 indica queda sem close

O 1006 é o que aparece nos logs de quem não entendeu uma queda. Ele significa que a conexão morreu sem close, então não há código a mostrar. Se você o vê em produção, comece pelo proxy ou por um timeout ocioso.

No teste de fechamento desta aula, o em loopback levou 22 ms. O número mostra o caminho feliz no próprio computador, e não diz nada sobre uma rede real.

Estados de uma conexão WebSocket: aberta, fechando após close 1000, 1001 ou 1009, e queda sem pong que termina em CloseNow sem handshake.
figura 3 · Ciclo de vida de uma conexão WebSocket

O caminho de cima é o fechamento combinado. O de baixo é a queda, em que nenhum close chega, a aplicação percebe a falha, e o encerramento usa CloseNow, que pula o aperto de mão porque o outro lado já não responde.

5. quando vale a pena e quando não

O WebSocket vale a pena quando as duas pontas falam com frequência e a latência importa. Chat, jogos, edição colaborativa e quadros interativos são exemplos. Abrir uma requisição HTTP por mensagem, milhares de vezes por minuto, custaria mais que manter a conexão.

Ele não vale a pena quando só um lado fala. O SSE faz o mesmo trabalho com menos peças: é HTTP puro, passa por proxies HTTP sem configuração especial, e o navegador reconecta sozinho usando o último ID recebido. Atualizações raras pedem polling ou webhook. E vale desconfiar quando a infraestrutura não lida bem com conexões longas, porque cada cliente prende um socket e memória no servidor.

CritérioSSEWebSocket
DireçãoSó do servidor para o clienteNos dois sentidos
ProtocoloHTTP com text/event-streamUpgrade para ws:// ou wss://
ReconexãoO navegador reconecta sozinhoSeu código reconecta
Retomada de eventos perdidosSim, com Last-Event-IDNão existe no protocolo
Tipos de dadoTexto UTF-8Texto ou binário
ProxiesPassam como HTTP, com cuidado de bufferPrecisam repassar Upgrade e Connection

A linha da retomada é a que mais muda a arquitetura. Se a conexão cai, as mensagens enviadas durante a queda se perdem, e a aplicação precisa guardar o histórico e oferecer um jeito de pedir o que faltou. A seção 8.3 trata disso.

6. implementação em go

6.1 a biblioteca

A biblioteca padrão tem net/http, mas não tem WebSocket. A github.com/gorilla/websocket é antiga e muito usada. A versão mais recente publicada no proxy de módulos do Go é a v1.5.3, de 14/06/2024. Esta aula não avaliou a manutenção atual do projeto.

A github.com/coder/websocket é a que esta aula usa. O README diz que o projeto foi mantido por nhooyr de 2019 a 2024 e que a Coder assumiu depois. A versão usada é a v1.8.15, e o go.mod pede Go 1.23. Ela pesa a favor por três motivos: leitura e escrita recebem um context.Context, o que combina com prazos; a API é pequena, com Accept, Dial, Read, Write, Ping, Close e CloseNow; e a documentação descreve o que acontece quando um prazo estoura.

Três regras da documentação moldam o código. Todos os métodos podem ser chamados ao mesmo tempo, exceto Read, então uma goroutine lê e outra escreve. É preciso ler sempre, porque os quadros de controle só são processados durante uma leitura. E qualquer erro de qualquer método fecha a conexão, inclusive a expiração de um contexto.

6.2 o eco mínimo

O menor servidor que funciona aceita a conexão e devolve cada mensagem:

package main

import (
	"log"
	"net/http"

	"github.com/coder/websocket"
)

func main() {
	http.HandleFunc("/eco", func(w http.ResponseWriter, r *http.Request) {
		c, err := websocket.Accept(w, r, nil)
		if err != nil {
			log.Println(err)
			return
		}
		defer c.CloseNow()

		ctx := r.Context()
		for {
			tipo, msg, err := c.Read(ctx)
			if err != nil {
				return
			}
			if err := c.Write(ctx, tipo, msg); err != nil {
				return
			}
		}
	})
	log.Fatal(http.ListenAndServe(":8090", nil))
}

Com nil nas opções, o Accept aceita apenas origens iguais ao host, que é o padrão seguro. O teste com um cliente que enviou “oi” passou:

=== RUN   TestMinimo
    eco_test.go:29: minimo: "oi"
--- PASS: TestMinimo (0.00s)
PASS
ok  	minimo	0.217s

O problema está no mesmo laço. Se o cliente para de ler, o Write espera, e enquanto espera ninguém lê do socket, nem os pings. Não há prazo na escrita, não há ping, o limite de tamanho é o padrão da biblioteca e não há autenticação nem fechamento ordenado. Com um cliente bem-comportado, nada disso aparece, e é por isso que esses problemas costumam aparecer em produção.

6.3 a versão de produção

A versão de produção tem três arquivos. O main.go sobe o servidor e cuida do desligamento. O servidor.go tem o handler, a autenticação, as goroutines de cada conexão e os prazos. O hub.go guarda a lista de clientes e faz o broadcast.

O main.go:

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"time"
)

func main() {
	s := novoServidor()

	endereco := os.Getenv("ADDR")
	if endereco == "" {
		endereco = ":8080"
	}
	srv := &http.Server{
		Addr:              endereco,
		Handler:           s.rotas(),
		ReadHeaderTimeout: 10 * time.Second,
	}
	go func() {
		log.Printf("ouvindo em %s", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}()

	sinal, parar := signal.NotifyContext(context.Background(), os.Interrupt)
	defer parar()
	<-sinal.Done()

	// Shutdown não espera por conexões WebSocket: elas saem do controle do
	// http.Server depois do handshake. Por isso mandamos fechar cada uma e
	// esperamos pelas goroutines, com prazo.
	s.encerrar()
	ctx, cancelar := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancelar()
	_ = srv.Shutdown(ctx)

	feito := make(chan struct{})
	go func() {
		s.conexoes.Wait()
		close(feito)
	}()
	select {
	case <-feito:
		log.Println("todas as conexões fecharam")
	case <-ctx.Done():
		log.Println("algumas conexões não fecharam no prazo")
	}
}

O http.Server.Shutdown não espera conexões sequestradas pelo handshake, como a documentação do pacote diz. Por isso o main cancela o contexto raiz, que manda o close 1001 para cada conexão, e só então espera pelas goroutines, com prazo de 10 segundos.

O hub.go:

package main

import "context"

// cliente é uma conexão registrada no hub. A fila envio guarda as mensagens que
// esperam para ser escritas no socket. A fila derrubado recebe o motivo quando o
// hub decide tirar o cliente da lista.
type cliente struct {
	envio     chan []byte
	derrubado chan string
}

func novoCliente() *cliente {
	return &cliente{
		envio:     make(chan []byte, 16),
		derrubado: make(chan string, 1),
	}
}

func (cl *cliente) derrubar(motivo string) {
	select {
	case cl.derrubado <- motivo:
	default:
	}
}

// hub guarda os clientes conectados e distribui cada mensagem para todos.
// Só a goroutine de executar lê e escreve no mapa de clientes. Quem quer mudar
// essa lista manda um pedido pelos canais, então não há mutex.
type hub struct {
	ctx      context.Context
	entrar   chan *cliente
	sair     chan *cliente
	publicar chan []byte
}

func novoHub(ctx context.Context) *hub {
	return &hub{
		ctx:      ctx,
		entrar:   make(chan *cliente),
		sair:     make(chan *cliente),
		publicar: make(chan []byte),
	}
}

func (h *hub) executar() {
	clientes := make(map[*cliente]struct{})
	for {
		select {
		case <-h.ctx.Done():
			return
		case cl := <-h.entrar:
			clientes[cl] = struct{}{}
		case cl := <-h.sair:
			delete(clientes, cl)
		case msg := <-h.publicar:
			for cl := range clientes {
				select {
				case cl.envio <- msg:
				default:
					// A fila encheu, então o cliente não está acompanhando.
					// Esperar por ele travaria todos os outros: o hub o derruba.
					delete(clientes, cl)
					cl.derrubar("cliente lento")
				}
			}
		}
	}
}

func (h *hub) adicionar(cl *cliente) {
	select {
	case h.entrar <- cl:
	case <-h.ctx.Done():
	}
}

func (h *hub) retirar(cl *cliente) {
	select {
	case h.sair <- cl:
	case <-h.ctx.Done():
	}
}

func (h *hub) difundir(msg []byte) {
	select {
	case h.publicar <- msg:
	case <-h.ctx.Done():
	}
}

O servidor.go:

package main

import (
	"context"
	"log"
	"net/http"
	"sync"
	"time"

	"github.com/coder/websocket"
)

const (
	limiteMensagem = 16 << 10 // bytes por mensagem recebida
	prazoEscrita   = 5 * time.Second
	intervaloPing  = 20 * time.Second
	prazoPong      = 10 * time.Second
)

// sessoes simula o armazenamento de sessões. Em produção, o cookie aponta para
// um armazenamento real, e o valor nunca fica fixo no código.
var sessoes = map[string]string{"s3cr3t-dev": "ana"}

type servidor struct {
	raiz     context.Context
	encerrar context.CancelFunc
	hub      *hub
	conexoes sync.WaitGroup
}

func novoServidor() *servidor {
	raiz, encerrar := context.WithCancel(context.Background())
	s := &servidor{raiz: raiz, encerrar: encerrar, hub: novoHub(raiz)}
	go s.hub.executar()
	return s
}

func (s *servidor) rotas() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /ws", s.ws)
	return mux
}

func sessaoValida(r *http.Request) bool {
	c, err := r.Cookie("sessao")
	if err != nil {
		return false
	}
	_, ok := sessoes[c.Value]
	return ok
}

func (s *servidor) ws(w http.ResponseWriter, r *http.Request) {
	// A autenticação acontece antes do Accept: se recusarmos aqui, a conexão
	// nem vira WebSocket.
	if !sessaoValida(r) {
		http.Error(w, "não autenticado", http.StatusUnauthorized)
		return
	}

	c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		// Origens permitidas além do próprio host. Em produção, o seu domínio.
		OriginPatterns: []string{"localhost:*", "127.0.0.1:*"},
	})
	if err != nil {
		// Accept já respondeu ao cliente (403 para origem estranha, por exemplo).
		log.Println("handshake recusado:", err)
		return
	}
	defer c.CloseNow()

	s.conexoes.Add(1)
	defer s.conexoes.Done()
	c.SetReadLimit(limiteMensagem)

	ctx, cancelar := context.WithCancel(s.raiz)
	defer cancelar()

	// Quando o servidor encerra, mandamos 1001 (going away) em vez de cortar o socket.
	parar := context.AfterFunc(s.raiz, func() {
		_ = c.Close(websocket.StatusGoingAway, "servidor encerrando")
	})
	defer parar()

	cli := novoCliente()
	s.hub.adicionar(cli)
	defer s.hub.retirar(cli)

	go escrever(ctx, c, cli)
	go manterVivo(ctx, c)

	// A leitura fica nesta goroutine. Read responde a ping e pong e trata o
	// frame de close por dentro, então não precisa de código extra para isso.
	for {
		tipo, msg, err := c.Read(context.Background())
		if err != nil {
			return
		}
		if tipo != websocket.MessageText {
			_ = c.Close(websocket.StatusUnsupportedData, "só mensagens de texto")
			return
		}
		s.hub.difundir(msg)
	}
}

// escrever é a única goroutine que escreve no socket desta conexão. Assim as
// mensagens saem na ordem em que entraram na fila, e um cliente lento só atrasa
// a própria fila.
func escrever(ctx context.Context, c *websocket.Conn, cli *cliente) {
	for {
		select {
		case <-ctx.Done():
			return
		case motivo := <-cli.derrubado:
			_ = c.Close(websocket.StatusPolicyViolation, motivo)
			return
		case msg := <-cli.envio:
			wctx, parar := context.WithTimeout(ctx, prazoEscrita)
			err := c.Write(wctx, websocket.MessageText, msg)
			parar()
			if err != nil {
				// Pela documentação da biblioteca, um erro de escrita já fecha a conexão.
				return
			}
		}
	}
}

// manterVivo manda um ping a cada intervalo. Sem pong no prazo, o par sumiu
// (queda de rede, celular sem sinal) e fechamos sem esperar o handshake de close,
// que não teria a quem responder.
func manterVivo(ctx context.Context, c *websocket.Conn) {
	t := time.NewTicker(intervaloPing)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			pctx, parar := context.WithTimeout(ctx, prazoPong)
			err := c.Ping(pctx)
			parar()
			if err != nil {
				_ = c.CloseNow()
				return
			}
		}
	}
}

O handler segue uma ordem. Primeiro confere a sessão no cookie, antes do Accept. Depois o Accept confere a origem, o limite de leitura cai para 16 KiB, e o contexto da conexão nasce do contexto raiz do servidor. Um context.AfterFunc manda o close 1001 quando o servidor é desligado.

Três goroutines atendem a conexão. A de escrita é a única que escreve no socket, então as mensagens saem na ordem em que entraram na fila. A de ping manda um ping a cada 20 segundos e espera 10 segundos pelo pong. O próprio handler lê, porque o Read é quem processa pong e close. Os prazos ficam no código: a escrita tem 5 segundos, e o ping sem resposta fecha a conexão com CloseNow, sem aperto de mão, porque o outro lado já não responde.

A documentação diz que qualquer erro fecha a conexão. Mas a leitura da função ping mostra que a expiração do contexto devolve o erro sem fechar o socket. Como o caminho de um par parado não foi exercitado nesta aula, o código fecha explicitamente.

6.4 os testes e a saída real

A suíte tem oito testes, com servidor de httptest e clientes de websocket.Dial. Eles cobrem o eco, o broadcast, as duas recusas do handshake, o limite de tamanho, o fechamento, o desligamento e o hub. Esta é a saída de go test -race -v:

=== RUN   TestEco
    servidor_test.go:69: eco: enviei "olá, servidor" e recebi "olá, servidor"
--- PASS: TestEco (0.01s)
=== RUN   TestBroadcast
    servidor_test.go:100: broadcast: a mensagem de a chegou nos dois clientes
--- PASS: TestBroadcast (0.02s)
=== RUN   TestSemSessaoRecebe401
    servidor_test.go:114: sem cookie: o handshake foi recusado com 401
--- PASS: TestSemSessaoRecebe401 (0.00s)
=== RUN   TestOrigemEstranhaRecebe403
2026/11/03 07:20:49 handshake recusado: failed to accept WebSocket connection: request Origin "site-malicioso.example" is not authorized for Host "127.0.0.1:63169"
    servidor_test.go:131: origem estranha: o handshake foi recusado com 403
--- PASS: TestOrigemEstranhaRecebe403 (0.00s)
=== RUN   TestMensagemGrandeFechaCom1009
    servidor_test.go:143: mensagem de 20 KB: o servidor fechou com 1009 (failed to get reader: received close frame: status = StatusMessageTooBig and reason = "read limited at 16385 bytes")
--- PASS: TestMensagemGrandeFechaCom1009 (0.00s)
=== RUN   TestFechamentoLimpo
    servidor_test.go:156: fechamento: close handshake concluído em 22ms, sem erro
--- PASS: TestFechamentoLimpo (0.03s)
=== RUN   TestEncerramentoMandaGoingAway
    servidor_test.go:167: servidor encerrando: cliente recebeu 1001 (failed to get reader: received close frame: status = StatusGoingAway and reason = "servidor encerrando")
--- PASS: TestEncerramentoMandaGoingAway (0.00s)
=== RUN   TestHubDerrubaClienteLento
    servidor_test.go:187: hub: cliente que não lia foi derrubado (motivo "cliente lento")
--- PASS: TestHubDerrubaClienteLento (0.00s)
PASS
ok  	aulaws	1.464s

TestBroadcast espera o eco do próprio cliente B antes de enviar a mensagem de teste, porque esse eco só volta depois que B está registrado no hub. TestMensagemGrandeFechaCom1009 envia 20 KB para um limite de 16 KiB e confirma o código 1009. TestHubDerrubaClienteLento enche a fila de um cliente que não lê e confirma o motivo da saída.

Os oito testes passaram com -race, e a suíte foi repetida cinco vezes sem falha. O binário também foi rodado de verdade na porta 8099 e a resposta foi capturada com curl:

$ curl -s -i -N -m 2 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Cookie: sessao=s3cr3t-dev" -H "Host: 127.0.0.1:8099" http://127.0.0.1:8099/ws | head -8
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-Websocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Upgrade: websocket

$ curl -s -i -m 2 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Host: 127.0.0.1:8099" http://127.0.0.1:8099/ws | head -5
HTTP/1.1 401 Unauthorized

$ curl -s -i -m 2 -H "Cookie: sessao=s3cr3t-dev" http://127.0.0.1:8099/ws | head -4
HTTP/1.1 426 Upgrade Required

log do servidor (SIGINT depois dos três pedidos):
2026/11/03 07:21:08 ouvindo em 127.0.0.1:8099
2026/11/03 07:21:11 handshake recusado: failed to accept WebSocket connection: WebSocket protocol violation: Connection header "" does not contain Upgrade
2026/11/03 07:21:11 todas as conexões fecharam

Os cabeçalhos Date foram removidos. A linha de log sobre a recusa vem do terceiro pedido, o GET sem cabeçalhos de upgrade, porque o Accept registra a falha. A recusa por sessão acontece antes do Accept e não gera linha de log.

6.5 o que estoura e o que não estoura

Um prazo passado para Read ou Write faz mais do que mandar parar de esperar. Na versão 1.8.15, quando o contexto expira durante a leitura ou a escrita, a biblioteca fecha a conexão, em vez de tentar retomar um quadro pela metade. Por isso o prazo de escrita de 5 segundos derruba um cliente travado, o que é o comportamento desejado, mas diferente do que o nome “timeout” sugere.

O http.Server tem WriteTimeout, que vale para requisições comuns, mas não para conexões sequestradas pelo Accept. O net/http zera os deadlines do socket no sequestro. Um experimento com WriteTimeout de 1 segundo e um handler que espera 2,5 segundos confirmou isso: a resposta HTTP comum falhou com EOF no cliente, e a mesma espera dentro de um WebSocket terminou com a mensagem entregue. É uma execução só, mas o código do net/http mostra o mecanismo. Os prazos de uma conexão WebSocket precisam estar no seu código.

Pergunta de entrevistaQuando você escolheria WebSocket e quando preferiria SSE ou polling?

O critério principal é a direção do fluxo. WebSocket vale a pena quando as duas pontas falam com frequência e a latência importa, como em chat, jogos e edição colaborativa. Se só o servidor fala, SSE é mais simples: é HTTP puro, passa pela infraestrutura existente sem configuração especial e o navegador reconecta sozinho com Last-Event-ID. Para atualizações raras, polling ou webhook bastam. O custo do WebSocket é que o código precisa reconectar, e o protocolo não tem retomada, então as mensagens enviadas durante uma queda se perdem e a aplicação precisa guardar histórico ou oferecer um snapshot. Além disso, cada cliente prende um socket e memória no servidor, o que exige atenção à infraestrutura.

7. hub de broadcast

O hub resolve um problema simples: uma mensagem chega de um cliente e precisa sair para os outros, enquanto a lista de clientes muda. Há duas formas comuns. Uma protege o mapa com sync.RWMutex e itera com o lock preso. A outra, usada aqui, deixa uma goroutine dona do mapa, e os demais pedem mudanças pelos canais.

A goroutine dona tem duas vantagens. O mapa nunca é tocado por duas goroutines ao mesmo tempo, então não há corrida para depurar. E a política de fica explícita. Cada cliente tem uma fila de 16 mensagens. O hub tenta colocar a mensagem na fila de cada um com envio sem bloqueio. Se a fila está cheia, o cliente não acompanha: o hub o tira da lista e registra o motivo “cliente lento” no canal derrubado. Esperar por ele travaria o envio para todos, que é o problema que um broadcast deve evitar.

Essa política tem um custo. O hub tira o cliente da lista na hora, mas o escritor só vê o motivo quando termina a escrita em andamento ou quando o prazo de 5 segundos dela estoura. Entre a decisão e o fechamento pode haver alguns segundos. Nada quebra, porque o cliente já não recebe mensagens, mas o fechamento não é instantâneo. Os testes cobrem a decisão do hub, e o caminho completo com um socket que parou de ler não foi medido.

Goroutines de uma conexão Go: a leitura passa a mensagem ao hub, que a põe na fila de envio, e a escrita única a manda ao socket, com ping em paralelo.
figura 4 · Goroutines de uma conexão e o hub de broadcast

A figura mostra o caminho de uma mensagem e a responsabilidade de cada goroutine. A escrita é a única que chega ao socket vinda do servidor, e é essa regra que impede duas escritas simultâneas. O pong volta pela leitura.

Hub de broadcast: se a fila do cliente tem vaga, a mensagem segue; se não tem, o cliente é derrubado com close 1008 sem travar os outros.
figura 5 · Hub de broadcast e o cliente que não lê

A segunda figura responde à pergunta do cliente que não lê. O losango confere a vaga. O sim leva a mensagem à escrita do cliente rápido. O não leva o motivo ao canal derrubado, e a escrita manda o close 1008. O hub segue com os outros sem esperar por ninguém.

8. cuidados de produção

8.1 limites de tamanho

Sem limite, um cliente pode mandar uma mensagem enorme, e a biblioteca precisa juntar os quadros em memória para entregá-la. O padrão da coder/websocket é 32 KiB, e a aula usou 16 KiB com SetReadLimit. O teste de 20 KB confirmou o fechamento com 1009. Escolha o valor pela aplicação. Um chat raramente passa de alguns KB. Para imagens, o melhor é enviar uma URL de upload, e não o conteúdo pelo socket. O limite vale por conexão: dez mil clientes com 16 KiB no pior caso somam cerca de 160 MiB.

8.2 heartbeat e queda

O ping da aula sai a cada 20 segundos, e a espera pelo pong é de 10 segundos. A documentação da biblioteca diz que keepalive TCP costuma bastar, mas o ping de aplicação prova que o outro lado processa quadros, e não só que o socket existe no sistema operacional. O intervalo precisa ser menor que o tempo ocioso de todo proxy e balanceador no caminho. A aula SSE registra que o ALB da AWS usa 60 segundos por padrão, consultado em 2026-10-27. Confirme o valor do seu ambiente antes de contar com ele.

8.3 reconexão no cliente

Se todos os clientes reconectam no mesmo instante depois de um reinício, o servidor recebe uma avalanche. A prática comum é o backoff exponencial com jitter: a espera dobra a cada falha, até um teto, e um valor aleatório espalha as tentativas. A espera volta a zero depois de uma conexão estável.

Reconectar não restaura o estado. Como o WebSocket não tem retomada, as mensagens da queda estão perdidas no nível do protocolo. O cliente precisa pedir um snapshot, ou o servidor precisa oferecer um histórico com IDs. Essa é a diferença mais importante em relação ao SSE, e nenhuma opção de biblioteca a resolve. Os exercícios da seção 10 pedem essa implementação.

8.4 autenticação e origem

A aula autentica antes do Accept, na ordem correta. O cookie de sessão vai no handshake porque o navegador o anexa sozinho, e isso abre uma porta: uma página maliciosa pode fazer o navegador da vítima abrir um WebSocket para o seu servidor, com os cookies dela. O ataque se chama sequestro de WebSocket entre sites, e a defesa tem duas partes, usadas aqui. A primeira é conferir o Origin contra uma lista explícita, com OriginPatterns. A segunda é tratar o cookie com HttpOnly, Secure e um SameSite adequado.

Evite token na query string. A URL costuma acabar em logs de proxies e balanceadores. Um ticket de uso único e curta duração, emitido por uma requisição autenticada, é uma alternativa, como o exercício da aula SSE sugere. Use wss:// em produção.

A aula não verificou se a API do navegador permite definir cabeçalhos no handshake, então não afirma nada sobre Authorization.

8.5 encerramento e escala

O desligamento da seção 6.3 evita que um deploy derrube todos os clientes no meio de uma frase. Com mais de uma instância, cada uma tem o seu hub, e uma mensagem na instância A não chega aos clientes da B. Para atravessar instâncias, é preciso um broker, como Redis em modo pub/sub, NATS ou Kafka. A aula de Mensageria Prática com RabbitMQ e Kafka trata dessas ferramentas.

9. erros comuns e depuração

Esquecer de ler a conexão é o erro mais caro. Um handler que só escreve nunca recebe o pong, e o ping espera até o prazo. O sintoma é uma conexão que cai sem motivo claro depois de alguns minutos.

Usar r.Context() como se durasse para sempre também engana. Ele é cancelado quando o handler retorna, e a documentação do Accept avisa que usá-lo depois do aceite pode levar a comportamento inesperado. Por isso a aula deriva o contexto da conexão do contexto raiz do servidor.

Chamar um prazo de “timeout” e esperar que ele só desista da operação também engana. Como a seção 6.5 mostra, o prazo de leitura ou escrita fecha o socket quando estoura.

Mapa compartilhado sem proteção passa nos testes bem-comportados e falha em produção. A goroutine dona do hub evita o problema, e a flag -race aponta a violação se ela aparecer.

Broadcast bloqueante deixa um cliente com rede ruim atrasar todos. Reconexão sem backoff derruba o servidor no momento em que ele tenta voltar. Confiar em entrega é outro erro: um Write que retorna sem erro saiu para o socket, mas o protocolo não tem confirmação de entrega. Se a regra exige entrega, a confirmação precisa ser uma mensagem sua, com ID.

Origem * fecha a porta que a seção 8.4 abre. A documentação do AcceptOptions pede que você não a use. Por fim, o 1006 nos logs, já explicado na seção 4, aponta para uma queda sem close, e a causa costuma estar no caminho de rede.

Pergunta de entrevistaQuais cuidados você toma ao colocar um servidor WebSocket em produção?

Limito o tamanho das mensagens, porque sem limite um cliente pode mandar uma mensagem enorme e a biblioteca junta os quadros em memória. Na aula usei SetReadLimit com 16 KiB, e o teste de 20 KB fechou a conexão com o código 1009. Uso heartbeat com ping e pong para provar que o outro lado processa quadros, com intervalo menor que o tempo ocioso de qualquer proxy no caminho. No cliente, reconecto com backoff exponencial e jitter, para evitar uma avalanche depois de um reinício. Como reconectar não restaura o estado, preciso de snapshot ou de um histórico com IDs. Por fim, autentico antes do Accept e valido a origem.

10. exercícios

  1. Conte mensagens por conexão. Adicione ao servidor.go um contador de mensagens por conexão e faça o eco devolver o número, como "1: olá, servidor". Antes de rodar, diga onde o contador precisa ficar para não ter corrida. Resposta de referência: o contador fica no handler, porque só a goroutine de leitura o toca, e não precisa de mutex. Se ele ficasse no hub, o acesso seria concorrente com outras conexões e precisaria de sincronização. O TestEco precisa ser ajustado, porque a resposta mudou.

  2. Limite a taxa por cliente a 5 mensagens por segundo, na goroutine de leitura, antes de s.hub.difundir. Ao passar do limite, feche com 1008. Escreva um teste com uma rajada de 20 mensagens. Resposta de referência: a conexão deve fechar com websocket.StatusPolicyViolation no sexto envio ou logo depois, e o teste confere websocket.CloseStatus(err) na leitura seguinte. Esta resposta é um desenho, não código executado.

  3. Reconecte com snapshot. Escreva um cliente com backoff exponencial e jitter, entre 100 ms e 10 s, que ao reconectar peça as últimas 10 mensagens. Resposta de referência: o buffer circular fica no hub, dono do estado, e um quadro especial como "!historico" é respondido pela leitura. A espera dobra a cada falha, com valor aleatório entre zero e a espera atual, e volta a zero depois de uma conexão estável. Também é um desenho, não código executado.

11. glossário

  • WebSocket: protocolo que mantém uma conexão TCP aberta depois de um handshake HTTP, com mensagens nos dois sentidos.
  • Handshake: o pedido HTTP com cabeçalhos de upgrade e a resposta 101 que aceita a troca.
  • Upgrade: troca de protocolo pedida no cabeçalho Upgrade e confirmada com 101.
  • Sec-WebSocket-Key e Sec-WebSocket-Accept: a chave do cliente e o valor que o servidor calcula a partir dela.
  • Quadro (frame): unidade de transmissão do WebSocket, com cabeçalho de bits e carga.
  • Opcode: os quatro bits que dizem o tipo do quadro, como texto, ping ou close.
  • Máscara: chave de quatro bytes com a qual o cliente cifra cada quadro, para que um script não controle os bytes na rede.
  • Ping e pong: quadros de controle que perguntam e respondem se o outro lado existe.
  • Close handshake: o fechamento de duas vias, com um close de cada lado.
  • Código de status: número de dois bytes no close que explica o motivo, como 1000 ou 1008.
  • CSWSH: sequestro de WebSocket entre sites, com os cookies da vítima.
  • Origin: cabeçalho com a origem da página que pediu a conexão.
  • Hub: goroutine dona da lista de clientes, que distribui as mensagens.
  • Backpressure: mecanismo que impede um produtor de encher a memória quando o consumidor não acompanha.
  • Heartbeat: ping periódico que detecta conexões mortas.
  • Backoff com jitter: espera crescente entre tentativas, com variação aleatória.
  • wss: WebSocket sobre TLS.

12. o que não foi confirmado

  • As regras de SameSite para cookies no handshake, em navegadores reais. Os testes usam clientes Go.
  • Se a API do navegador permite definir cabeçalhos customizados no handshake. A página da MDN consultada não diz isso.
  • O comportamento de proxies e balanceadores reais com WebSocket. Nenhum foi configurado. O número de 60 segundos do ALB vem de outra aula.
  • O caminho completo do cliente lento, com socket real parado. Só a decisão do hub foi testada.
  • O fechamento por ping sem pong com um par que parou de responder. O código foi escrito a partir da documentação e da leitura da função ping, e o caminho não foi exercitado.
  • Memória e descritores por conexão, vazão e latência sob carga. Nada foi medido em escala.
  • A manutenção atual do gorilla/websocket. Só a data da última versão publicada foi registrada.
  • O experimento com WriteTimeout vale para a execução com Go 1.27.2, feita uma vez.
  • As respostas dos exercícios 2 e 3 são desenhos, não código executado.

13. fontes

Consultadas em 03/11/2026:

Saídas capturadas em 03/11/2026: go test -race -v com os oito testes, cinco repetições sem falha, o eco mínimo, a captura do handshake contra o binário da aula e o experimento com WriteTimeout.

relacionados

anexo: código dos testes

O servidor_test.go fica no mesmo módulo, aulaws, com go 1.23, ao lado de main.go, servidor.go e hub.go. Para testar, rode go test -race -v ./.... Para subir o servidor, rode go run ., com ADDR opcional.

package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/coder/websocket"
)

const cookieValido = "sessao=s3cr3t-dev"

func subir(t *testing.T) (string, *servidor) {
	t.Helper()
	s := novoServidor()
	srv := httptest.NewServer(s.rotas())
	t.Cleanup(func() {
		s.encerrar()
		srv.Close()
	})
	return "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws", s
}

func conectar(t *testing.T, url, cookie string) *websocket.Conn {
	t.Helper()
	h := http.Header{}
	if cookie != "" {
		h.Set("Cookie", cookie)
	}
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
	if err != nil {
		t.Fatalf("dial falhou: %v", err)
	}
	t.Cleanup(func() { _ = c.CloseNow() })
	return c
}

func enviar(t *testing.T, c *websocket.Conn, msg string) {
	t.Helper()
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	if err := c.Write(ctx, websocket.MessageText, []byte(msg)); err != nil {
		t.Fatalf("write falhou: %v", err)
	}
}

func ler(t *testing.T, c *websocket.Conn) string {
	t.Helper()
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	_, msg, err := c.Read(ctx)
	if err != nil {
		t.Fatalf("read falhou: %v", err)
	}
	return string(msg)
}

func TestEco(t *testing.T) {
	url, _ := subir(t)
	c := conectar(t, url, cookieValido)

	enviar(t, c, "olá, servidor")
	got := ler(t, c)
	t.Logf("eco: enviei %q e recebi %q", "olá, servidor", got)
	if got != "olá, servidor" {
		t.Fatalf("eco devolveu %q", got)
	}
}

func TestBroadcast(t *testing.T) {
	url, _ := subir(t)
	a := conectar(t, url, cookieValido)
	enviar(t, a, "a-pronto")
	if got := ler(t, a); got != "a-pronto" {
		t.Fatalf("a esperava o próprio eco, recebeu %q", got)
	}

	b := conectar(t, url, cookieValido)
	enviar(t, b, "b-pronto")
	if got := ler(t, b); got != "b-pronto" {
		t.Fatalf("b esperava o próprio eco, recebeu %q", got)
	}
	// Quando b recebe o próprio eco, ele já está registrado no hub.
	if got := ler(t, a); got != "b-pronto" {
		t.Fatalf("a deveria receber a mensagem de b, recebeu %q", got)
	}

	enviar(t, a, "oi")
	if got := ler(t, b); got != "oi" {
		t.Fatalf("b recebeu %q", got)
	}
	if got := ler(t, a); got != "oi" {
		t.Fatalf("a recebeu %q", got)
	}
	t.Log("broadcast: a mensagem de a chegou nos dois clientes")
}

func TestSemSessaoRecebe401(t *testing.T) {
	url, _ := subir(t)
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	_, resp, err := websocket.Dial(ctx, url, nil)
	if err == nil {
		t.Fatal("conexão sem sessão não deveria abrir")
	}
	if resp == nil || resp.StatusCode != http.StatusUnauthorized {
		t.Fatalf("esperava 401, veio %v", resp)
	}
	t.Logf("sem cookie: o handshake foi recusado com %d", resp.StatusCode)
}

func TestOrigemEstranhaRecebe403(t *testing.T) {
	url, _ := subir(t)
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	h := http.Header{}
	h.Set("Cookie", cookieValido)
	h.Set("Origin", "https://site-malicioso.example")
	_, resp, err := websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
	if err == nil {
		t.Fatal("origem fora da lista não deveria abrir")
	}
	if resp == nil || resp.StatusCode != http.StatusForbidden {
		t.Fatalf("esperava 403, veio %v", resp)
	}
	t.Logf("origem estranha: o handshake foi recusado com %d", resp.StatusCode)
}

func TestMensagemGrandeFechaCom1009(t *testing.T) {
	url, _ := subir(t)
	c := conectar(t, url, cookieValido)
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	// O limite é 16 KiB. Esta mensagem tem 20 KB.
	_ = c.Write(ctx, websocket.MessageText, []byte(strings.Repeat("x", 20<<10)))
	_, _, err := c.Read(ctx)
	st := websocket.CloseStatus(err)
	t.Logf("mensagem de 20 KB: o servidor fechou com %d (%v)", st, err)
	if st != websocket.StatusMessageTooBig {
		t.Fatalf("esperava 1009, veio %d", st)
	}
}

func TestFechamentoLimpo(t *testing.T) {
	url, _ := subir(t)
	c := conectar(t, url, cookieValido)
	inicio := time.Now()
	if err := c.Close(websocket.StatusNormalClosure, "até logo"); err != nil {
		t.Fatalf("close falhou: %v", err)
	}
	t.Logf("fechamento: close handshake concluído em %v, sem erro", time.Since(inicio).Round(time.Millisecond))
}

func TestEncerramentoMandaGoingAway(t *testing.T) {
	url, s := subir(t)
	c := conectar(t, url, cookieValido)
	s.encerrar()
	ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelar()
	_, _, err := c.Read(ctx)
	st := websocket.CloseStatus(err)
	t.Logf("servidor encerrando: cliente recebeu %d (%v)", st, err)
	if st != websocket.StatusGoingAway {
		t.Fatalf("esperava 1001, veio %d", st)
	}
}

func TestHubDerrubaClienteLento(t *testing.T) {
	ctx, cancelar := context.WithCancel(context.Background())
	defer cancelar()
	h := novoHub(ctx)
	go h.executar()

	cl := novoCliente()
	h.adicionar(cl)
	// Ninguém lê cl.envio, que tem 16 vagas. A 17ª mensagem não cabe.
	for i := 0; i < 17; i++ {
		h.difundir([]byte("x"))
	}
	select {
	case motivo := <-cl.derrubado:
		t.Logf("hub: cliente que não lia foi derrubado (motivo %q)", motivo)
	case <-time.After(2 * time.Second):
		t.Fatal("o hub não derrubou o cliente que não lia")
	}
}

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?