8 min de leituraEngenharia

Como encurtar uma URL em Go com net/http e errgroup

Encurte uma URL em Go com net/http e um corpo JSON; depois adicione um prazo para o contexto, novas tentativas com uma Idempotency-Key e encurtamento em lote limitado com errgroup.

Marius Voß
DevRel · edge infra
Como encurtar uma URL em Go: um POST com net/http envia uma URL de destino para uma API de encurtamento e decodifica o link curto, com um prazo para o contexto e uma distribuição limitada de goroutines com errgroup

Encurtar uma URL em Go é fazer um POST com um corpo JSON. Converta o destino, envie-o para o endpoint de links do encurtador usando net/http, passe a sua chave de API como um token Bearer e decodifique short_url da resposta. A biblioteca padrão faz tudo isso, e a única dependência que vale a pena adicionar depois é errgroup para trabalhos em lote.

Os resultados de busca sobre isso são dominados por projetos de "crie seu próprio encurtador em Go", que são um exercício diferente: eles armazenam links, enquanto este artigo chama um serviço que já faz isso. Se você quer cuidar do armazenamento, como criar um encurtador de URLs aborda as decisões de design. Se quer um link curto nos próximos cinco minutos, continue lendo.

Este é o artigo sobre Go de uma série que também aborda Python, JavaScript e PHP. O formato do endpoint, o modelo de autenticação e os limites do plano gratuito considerados aqui estão documentados na visão geral da API gratuita de encurtamento de URLs.

A forma mais rápida: um POST com net/http

Duas estruturas, uma requisição, uma decodificação:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type linkRequest struct {
	DestinationURL string `json:"destination_url"`
}

type linkResponse struct {
	ID       string `json:"id"`
	ShortURL string `json:"short_url"`
}

func main() {
	body, err := json.Marshal(linkRequest{
		DestinationURL: "https://example.com/spring-sale?utm_source=newsletter",
	})
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequest(http.MethodPost, "https://api.elido.app/v1/links", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		panic("shorten failed: " + resp.Status)
	}

	var link linkResponse
	if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
		panic(err)
	}

	fmt.Println(link.ShortURL) // https://s.elido.me/ab12cd
}

Execute go run main.go com ELIDO_API_KEY exportada e você terá um link curto. Os panics são aceitáveis em um programa de quinze linhas e estão errados em qualquer outro lugar; a próxima seção transforma isso em uma função que retorna erros.

A linha client := &http.Client{Timeout: ...} é a que deve ser mantida. http.DefaultClient não tem tempo limite, então http.Post contra um host que não responde bloqueia essa goroutine até que a conexão morra por conta própria, o que em uma rede ruim pode levar minutos.

Dê um prazo a toda requisição e compartilhe um cliente

Formato de produção: um cliente no nível do pacote, um contexto em cada requisição e erros no lugar de panics.

var client = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		MaxIdleConns:        64,
		MaxIdleConnsPerHost: 16, // default is 2, too low for a concurrent batch
		IdleConnTimeout:     90 * time.Second,
	},
}

const endpoint = "https://api.elido.app/v1/links"

func shorten(ctx context.Context, destination string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	body, err := json.Marshal(linkRequest{DestinationURL: destination})
	if err != nil {
		return "", fmt.Errorf("marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("post %s: %w", endpoint, err)
	}
	defer func() {
		io.Copy(io.Discard, resp.Body) // drain so the connection can be reused
		resp.Body.Close()
	}()

	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("shorten: %s", resp.Status)
	}

	var link linkResponse
	if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
		return "", fmt.Errorf("decode: %w", err)
	}
	return link.ShortURL, nil
}

Três detalhes justificam seu lugar. Reutilizar um único http.Client mantém o pool de conexões vivo entre as chamadas; criar um cliente por requisição descarta todo o keep-alive. Drenar o corpo antes de fechá-lo é o que realmente permite que a conexão volte ao pool, e esse é o motivo habitual para um trabalho em lote abrir muito mais sockets do que deveria. E context na requisição significa que quem chama e desiste - uma CLI cancelada ou uma pessoa que desconectou - interrompe a chamada HTTP em vez de deixá-la em execução.

MaxIdleConnsPerHost merece o comentário. O padrão é 2, algo invisível em um script e caro quando oito goroutines estão acessando o mesmo host.

Um POST Go com net/http levando um prazo para o contexto, um token Bearer e uma Idempotency-Key ao endpoint de links do encurtador; a API retorna HTTP 201 com short_url, e um caminho de nova tentativa recua em 429 e 5xx enquanto respeita o cancelamento do contexto

Faça novas tentativas em 429 e 5xx sem criar duplicatas

Há um modo de falha que um loop simples de novas tentativas piora. O POST chega à API, o link é criado e a resposta se perde no caminho de volta. Seu código vê um tempo limite, tenta novamente e agora dois links curtos apontam para um único destino. Uma Idempotency-Key resolve isso: mesma chave, mesma requisição lógica, e a API retorna o link original em vez de criar outro.

Derive a chave do destino para que ela permaneça estável entre novas tentativas e entre reexecuções do mesmo lote.

var (
	errRateLimited = errors.New("rate limited")
	errServer      = errors.New("server error")
)

func shortenWithRetry(ctx context.Context, destination string, attempts int) (string, error) {
	key := fmt.Sprintf("%x", sha256.Sum256([]byte(destination)))

	var lastErr error
	for attempt := 0; attempt < attempts; attempt++ {
		short, retryAfter, err := postLink(ctx, destination, key)
		if err == nil {
			return short, nil
		}

		var wait time.Duration
		switch {
		case errors.Is(err, errRateLimited):
			wait = retryAfter // from the Retry-After header
		case errors.Is(err, errServer):
			wait = time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
		default:
			return "", err // 401, 403, 422: retrying will not help
		}

		lastErr = err
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(wait):
		}
	}
	return "", fmt.Errorf("shorten %q: %w", destination, lastErr)
}

Observe o select no lugar de time.Sleep. Dormir ignora o cancelamento, então um trabalho instruído a ser encerrado ainda fica parado por quatro segundos por URL. Esperar simultaneamente por ctx.Done() e time.After torna o recuo interrompível, e essa é a diferença entre um encerramento limpo e um SIGKILL. O guia aprofundado sobre limites de taxa e idempotência aborda a semântica do cabeçalho e explica por que novas tentativas cegas transformam uma indisponibilidade em duas.

Quer executar isso contra um endpoint ativo? Crie uma chave no plano gratuito, exporte ELIDO_API_KEY e cada trecho aqui compilará e será executado como está.

Encurte em lote com errgroup e SetLimit

A versão concorrente ingênua inicia uma goroutine por URL. Com 5.000 URLs, são 5.000 requisições simultâneas, e a API responde à maioria com 429. errgroup com SetLimit fornece uma distribuição com teto:

func shortenAll(ctx context.Context, urls []string) (map[string]string, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8) // at most 8 requests in flight, whatever len(urls) is

	var mu sync.Mutex
	out := make(map[string]string, len(urls))

	for _, u := range urls {
		g.Go(func() error {
			short, err := shortenWithRetry(ctx, u, 3)
			if err != nil {
				return fmt.Errorf("%s: %w", u, err)
			}
			mu.Lock()
			defer mu.Unlock()
			out[u] = short
			return nil
		})
	}

	return out, g.Wait()
}

No Go 1.22 e posteriores, a variável do loop é própria de cada iteração, então a antiga linha u := u dentro do loop desapareceu. O mutex continua sendo necessário: um mapa escrito por várias goroutines sem um é uma condição de corrida de dados, e go test -race informará isso.

Uma goroutine por URL sobrecarregando a API de encurtamento com requisições limitadas por taxa, em comparação com um errgroup usando SetLimit para manter um número limitado de requisições de encurtamento em lote em andamento

Há um comportamento que você precisa conhecer antes de publicar: errgroup.WithContext cancela o contexto compartilhado assim que qualquer goroutine retorna um erro, então a primeira falha grave interrompe o restante do lote. Isso é o que você quer em uma etapa de compilação que precisa ser totalmente bem-sucedida ou totalmente falhar. Para um importador que deve encurtar tudo o que conseguir e informar as falhas ao final, reúna os erros em uma fatia, retorne nil de cada goroutine e mantenha o grupo apenas como limitador de concorrência.

Cliente tipado ou net/http puro

Para um endpoint, o código acima é toda a integração, e uma dependência não traz nenhum benefício. O cálculo muda quando você começa a listar links com paginação, filtrar por tag, ler totais de cliques e lidar com meia dúzia de formatos de resposta: é nesse momento que modelos gerados e um cliente que já conhece as regras de novas tentativas economizam mais do que custam. A página de API e SDKs tem a lista atual, e o início rápido dos SDKs mostra a versão tipada dessa mesma chamada.

De qualquer forma, os hábitos são idênticos: chave vinda do ambiente, um prazo em toda requisição, código de status verificado antes do corpo, uma chave de idempotência estável em tudo que possa ser executado duas vezes e um teto de concorrência. Quando os links passam a ser criados por um serviço em vez de um laptop, webhooks para eventos de links são melhores que polling para descobrir o que aconteceu com eles, e o que a plataforma oferece aos desenvolvedores cobre o restante da superfície.

Leia a série principal

Este artigo faz parte do cluster de engenharia. Comece pelo guia da API gratuita de encurtamento de URLs para entender o formato do endpoint e a autenticação; depois leia o artigo sobre limites de taxa e idempotência para o comportamento sob carga. A referência ativa está na documentação da API, e encurtadores de URLs para desenvolvedores aborda o que verificar em uma API antes de construir algo sobre ela.

Relacionado no blog

Perguntas frequentes

Como encurto uma URL em Go?

Converta a URL longa em um corpo JSON, faça um POST para o endpoint de links de um encurtador usando net/http, defina a sua chave de API como um cabeçalho Bearer e decodifique short_url da resposta. A biblioteca padrão cobre tudo, então a função inteira tem cerca de vinte linhas e não precisa de dependências de terceiros.

Preciso de uma biblioteca para encurtar URLs em Go?

Não. net/http e encoding/json são suficientes para um único POST, e o único pacote externo que vale a pena adicionar é golang.org/x/sync/errgroup para concorrência limitada em trabalhos em lote. Um SDK gerado compensa quando você usa muitos endpoints e quer modelos tipados e paginação, em vez de uma única chamada.

Como defino um tempo limite para uma requisição HTTP em Go?

Defina Timeout no seu próprio http.Client e crie as requisições com http.NewRequestWithContext e context.WithTimeout. http.DefaultClient não tem tempo limite algum, então um servidor travado bloqueará uma goroutine para sempre. O tempo limite do cliente cobre toda a troca; o contexto também permite que quem chama cancele mais cedo.

Como encurto muitas URLs simultaneamente em Go?

Use errgroup com SetLimit para que apenas um número fixo de requisições esteja em andamento, em vez de iniciar uma goroutine por URL e sofrer limitação de taxa. Proteja o mapa de resultados com um mutex e envie uma Idempotency-Key estável por URL para que novas tentativas nunca criem links duplicados.

Por que meu cliente HTTP em Go não está reutilizando conexões?

Geralmente porque o corpo da resposta nunca é lido por completo antes de ser fechado, então a conexão não pode voltar ao pool de conexões ociosas. Drene-o com io.Copy(io.Discard, resp.Body) antes de Close. A outra causa comum é o padrão de duas conexões ociosas por host no transporte, o que é pouco para um trabalho em lote concorrente.

Por que minha requisição Go para um encurtador de URLs retorna 401?

O cabeçalho Authorization está ausente, foi escrito incorretamente ou contém uma variável de ambiente vazia. Imprima os.Getenv antes da requisição para confirmar que a chave foi carregada e verifique se o cabeçalho lê 'Bearer ' com o espaço final antes da chave. Um 403, por outro lado, significa que a chave é válida, mas não tem o escopo necessário para esse endpoint.

Experimente Elido

Cole uma URL, obtenha um link curto

Sem cadastro. O link vive 30 dias. Cadastre-se para mantê-lo para sempre.

Grátis, sem necessidade de registo · 2 por dia

Experimente o Elido

Encurtador de URL hospedado na UE: domínios personalizados, análises profundas e API aberta. Plano gratuito - sem cartão de crédito.

Tags
how to shorten a url in go
golang url shortener api
shorten url go net/http
go http client post json
bulk shorten urls golang
errgroup setlimit

Continuar lendo