CodeGym /Cursos /Go SELF /Propagacion del contexto: HTTP → app → storage; CLI → imp...

Propagacion del contexto: HTTP → app → storage; CLI → import → storage

Go SELF
Nivel 70, Lección 3
Disponible

1. Por que propagar ctx por las capas

Si ya has escrito handlers HTTP o comandos CLI, seguro que has tenido la sensacion de: «bueno, ya devolvi la respuesta/imprimi el resultado, ¿que mas da?». La diferencia aparece en el momento en que el usuario se va, la conexion se corta, expira el timeout o la operacion se vuelve demasiado larga. Sin propagar el contexto, las capas inferiores siguen trabajando «en el vacio»: leen archivos, calculan, van al almacenamiento y mantienen bloqueos. Es como seguir preparando una pizza despues de que el cliente haya cancelado el pedido: puede estar rica, pero el modelo de negocio sufre.

En Go, el contexto esta pensado como la forma estandar de llevar por las fronteras de la API tres cosas: cancelacion, deadline y metadatos de la operacion. Esto es especialmente importante en codigo de servidor, donde una sola peticion entrante suele desencadenar varias suboperaciones.

Modelo mental: una operacion → un ctx, que baja hacia abajo

Cuando decimos «propagar el contexto», no es magia ni un ritual. Es un acuerdo directo: tenemos un limite superior de la operacion (peticion HTTP o comando CLI), y justo ahi obtenemos o creamos el ctx raiz. A partir de ahi, ese mismo ctx (o su contexto hijo) se pasa hacia abajo por la pila de llamadas: handler → app/service → storage. Y ninguna capa en el camino lo «reinicia» a context.Background().

Si imaginaramos el sistema como una carrera de relevos, ctx seria el testigo. Se cae el testigo — el equipo sigue corriendo, pero el resultado ya no cuenta, porque la cancelacion y el deadline se han perdido. Y tambien se perderan metadatos como request_id, y los logs se convertiran en una mision de «adivina que peticion era esta».

2. Ejemplo: Tasker y contratos entre capas

Para que los ejemplos no sean un conjunto de piezas abstractas, vamos a asumir que tenemos una aplicacion de practica Tasker: guarda tareas y puede importarlas (por ejemplo, desde CSV). No nos adentramos en una arquitectura compleja, pero mantenemos una division clara:

  • Capa HTTP: recibe la peticion, establece un timeout y llama a la logica de app.
  • Capa App: operaciones de negocio (crear una tarea, importar tareas).
  • Capa Storage: almacenamiento (para el ejemplo, en memoria).

Esquema (simplificado):

flowchart TD
  HTTP[HTTP handler<br/>r.Context + WithTimeout] --> APP[app.Service]
  APP --> ST[storage.TaskStorage]
  CLI[CLI command<br/>Background + WithTimeout] --> APP
  APP --> ST

La idea principal: tanto HTTP como CLI conducen a la misma capa app, y el contexto va como primer parametro en todas partes.

Contratos entre capas: ctx es el primer parametro

Ahora viene un punto importante que parece una «pequena obsesion por el estilo», pero luego ahorra horas. Hacemos que cualquier operacion que pueda ser larga o cancelable reciba ctx como primer parametro. Es una marca visual: «esta funcion respeta el contexto».

Interfaz de storage

Empecemos desde abajo: storage sabe crear una tarea y devolver la lista.

package storage

import "context"

type Task struct {
	ID    int
	Title string
	Done  bool
}

type TaskStorage interface {
	Create(ctx context.Context, t Task) (Task, error)
	List(ctx context.Context) ([]Task, error)
}

Fijate en esto: no almacenamos ctx dentro de TaskStorage. El contexto pertenece a la llamada Create/List, no al «almacenamiento como objeto». Ese es precisamente el principio que se recomienda seguir en la API de Go: el contexto se pasa como argumento y no se guarda en un campo.

Capa app/service

La capa app «habla en lenguaje humano»: AddTask, ImportCSV.

package app

import (
	"context"
	"tasker/internal/storage"
)

type Service struct {
	st storage.TaskStorage
}

func NewService(st storage.TaskStorage) *Service {
	return &Service{st: st}
}

Y el metodo (breve, sin detalles de validacion):

package app

import (
	"context"
	"tasker/internal/storage"
)

func (s *Service) AddTask(ctx context.Context, title string) (storage.Task, error) {
	t := storage.Task{Title: title}
	return s.st.Create(ctx, t)
}

Idea importante: la capa app no inventa un nuevo contexto, respeta el de entrada y lo pasa mas adelante.

3. HTTP → app → storage: como no perder ctx

HTTP es el ejemplo ideal de «limite de la operacion»: la peticion empezo, la peticion termino. El servidor HTTP de Go ya tiene un contexto listo para la peticion: r.Context(). Se cancela cuando el cliente se va o cuando el handler termina (segun la situacion). Por eso ya tenemos el punto de partida de serie.

Handler: tomamos r.Context() y anadimos timeout

Una practica habitual es limitar el tiempo de procesamiento de la peticion. No dentro de storage, no «en algun punto profundo», sino directamente en el handler: es el lugar donde entiendes las expectativas del cliente.

package httpapi

import (
	"context"
	"net/http"
	"time"
)

func withRequestTimeout(r *http.Request, d time.Duration) (context.Context, func()) {
	ctx, cancel := context.WithTimeout(r.Context(), d)
	return ctx, cancel
}

Ahora lo aplicamos en el handler:

package httpapi

import (
	"net/http"
	"time"
)

func (h *Handler) handleAddTask(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := withRequestTimeout(r, 2*time.Second)
	defer cancel()

	_, _ = h.svc.AddTask(ctx, "buy milk")
	w.WriteHeader(http.StatusCreated)
}

Si, el ejemplo esta simplificado a proposito (no leemos JSON/formulario), porque ahora mismo nos interesa la ruta del contexto. El timeout se establece en el limite, y cancel() se llama con disciplina.

¿Por que defer cancel() es obligatorio incluso con timeout? Porque WithTimeout crea un temporizador interno/recursos, y la cancelacion temprana los libera antes. Es parte de una buena higiene.

La capa app no «reinicia» nada

Una capa app correcta parece aburrida. Y eso es un cumplido.

package app

import (
	"context"
	"tasker/internal/storage"
)

func (s *Service) ListTasks(ctx context.Context) ([]storage.Task, error) {
	return s.st.List(ctx)
}

La «optimizacion» mas peligrosa de un principiante es escribir esto:

// MAL: perdimos cancelacion/timeout/metadatos.
return s.st.List(context.Background())

Eso rompe toda la idea: el handler podia haber sido cancelado, pero storage ya no se enteraria.

Storage: al menos comprobamos minimamente ctx.Done()

En un almacenamiento en memoria la operacion suele ser rapida, pero aqui mismo formamos el habito. Dejemos que List compruebe la cancelacion antes de trabajar (como minimo): asi el codigo se comporta de forma predecible, incluso si mas adelante sustituyes storage por uno de archivos o de red.

package memstore

import (
	"context"
	"sync"
	"tasker/internal/storage"
)

type Store struct {
	mu    sync.Mutex
	tasks []storage.Task
	next  int
}

func (s *Store) List(ctx context.Context) ([]storage.Task, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	out := append([]storage.Task(nil), s.tasks...)
	return out, nil
}

Aqui hemos dado dos pasos importantes: comprobado la cancelacion y copiado el slice (para no devolver al exterior la memoria interna «viva»). En el contexto de esta charla, lo clave es ctx.Err() como causa correcta de finalizacion.

4. CLI → import → storage: Background() en el limite

CLI se diferencia de HTTP en un detalle importante: no tienes un r.Context() listo. Pero si tienes un «limite de la operacion» en forma de comando. Eso significa: el contexto raiz lo creas tu, normalmente mediante context.Background().

En una aplicacion real a menudo se anade cancelacion por senales del sistema operativo, pero hoy nos basta con entender la ruta: CLI crea ctx y lo pasa a app y storage.

Punto de entrada de CLI: creamos ctx y ponemos timeout al comando

Imaginemos el comando tasker import --file tasks.csv. Queremos que la importacion no cuelgue indefinidamente.

package main

import (
	"context"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	_ = ctx
	// despues: abrir el archivo y llamar a svc.ImportCSV(ctx, f)
}

Aqui de nuevo el timeout se establece en el limite de la operacion. Es el mismo principio que en HTTP.

El metodo de importacion de app recibe ctx y io.Reader

La importacion es un gran ejemplo de «capas»: CLI trabaja con el archivo, pero la capa app no tiene por que saber nada sobre os.File. Le basta con io.Reader. Y el contexto.

package app

import (
	"context"
	"encoding/csv"
	"fmt"
	"io"
)

func (s *Service) ImportCSV(ctx context.Context, r io.Reader) (int, error) {
	cr := csv.NewReader(r)

	n := 0
	for {
		select {
		case <-ctx.Done():
			return n, ctx.Err()
		default:
		}

		rec, err := cr.Read()
		if err == io.EOF {
			return n, nil
		}
		if err != nil {
			return n, fmt.Errorf("read csv: %w", err)
		}

		if len(rec) == 0 {
			continue
		}
		if _, err := s.AddTask(ctx, rec[0]); err != nil {
			return n, err
		}
		n++
	}
}

Este codigo hace dos cosas que hoy nos interesan mas. Primero, comprueba regularmente ctx.Done() dentro del bucle: es un «punto de salida» para una operacion larga. Segundo, no crea un nuevo contexto: usa exactamente el que llego desde arriba.

Capa CLI: abrio el archivo, paso ctx hacia abajo

Ahora vinculamos esto con os.Open. La capa CLI se ocupa de los detalles del archivo; la capa app, de la importacion como operacion de negocio.

package main

import (
	"context"
	"os"
	"time"
)

func runImport(path string, svc *Service) error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()

	_, err = svc.ImportCSV(ctx, f)
	return err
}

Fijate en la «capa cultural» del codigo: ctx aparecio en el nivel superior de la funcion, y desde ahi baja, como una maleta sin asa que aun asi cargamos porque es nuestra maleta.

5. Timeouts y ciclo de vida del contexto

Donde poner los timeouts: en el limite o dentro

La pregunta «donde poner el timeout» parece filosofica hasta que encuentras un bug: la peticion murio y tu storage sigue trabajando. La practica general es esta: el timeout se fija donde el sentido de la operacion para el usuario queda claro.

A continuacion, una pequena tabla de «como pensar», sin pretender ser la unica verdad:

Capa ¿Tiene derecho a establecer timeout? Por que
HTTP handler Si Es el mas cercano al cliente y entiende el SLA de la peticion
Comando CLI Si Describe la «operacion del usuario» (importar, exportar, listar)
Capa app A veces Puede fijar un sub-timeout para una suboperacion, pero solo a partir del ctx de entrada
Capa storage Normalmente no Storage debe respetar ctx, no imponer sus propias reglas a todos

Si la capa app necesita un limite mas corto para una suboperacion, se hace solo asi: context.WithTimeout(parentCtx, d). Los contextos forman un arbol, y el hijo heredara la cancelacion del padre, no la sustituira.

Por que no se puede «por comodidad» guardar ctx en un struct

La tentacion parece muy inocente: «bueno, si al final siempre llamo a los metodos del servicio, ¿por que no guardar ctx dentro de Service?». Porque entonces mezclas ciclos de vida. El contexto pertenece a una sola operacion, mientras que Service vive mucho tiempo. Como resultado, la siguiente llamada al servicio puede trabajar por accidente con un contexto ya cancelado o con un deadline de «ayer». Y obtendras un comportamiento del tipo «a veces funciona, a veces no» — el genero de bugs favorito, que quita el sueno.

La recomendacion oficial de Go para la mayoria de los casos es simple: los contextos no se guardan en estructuras; se pasan como argumento a los metodos, para que cada llamada tenga su propio ciclo de vida y sus propios deadlines/metadatos.

6. Diagramas de secuencia: HTTP y CLI

A veces es util ver no el codigo, sino la «animacion» de quien llama a quien y por donde fluye ctx.

Peticion HTTP

sequenceDiagram
  participant C as Client
  participant H as HTTP handler
  participant S as app.Service
  participant ST as storage

  C->>H: Request
  Note over H: ctx = r.Context()<br/>ctx = WithTimeout(ctx, 2s)
  H->>S: AddTask(ctx, title)
  S->>ST: Create(ctx, Task)
  ST-->>S: (Task, nil) o ctx.Err()
  S-->>H: result
  H-->>C: 201 Created

Importacion CLI

sequenceDiagram
  participant U as User
  participant CLI as CLI main
  participant S as app.Service
  participant ST as storage

  U->>CLI: tasker import tasks.csv
  Note over CLI: ctx = Background()<br/>ctx = WithTimeout(ctx, 10s)
  CLI->>S: ImportCSV(ctx, file)
  loop each row
    S->>ST: Create(ctx, Task)
  end
  ST-->>S: ok / ctx.Err()
  S-->>CLI: count / error
  CLI-->>U: exit code + message

Y en ambas ramas se mantiene la misma disciplina clave: ctx no se «pone a cero» y no se sustituye por Background() en mitad del camino.

7. Errores tipicos

Error n.º 1: «Reiniciar» el contexto a context.Background() en algun punto de app/storage, porque asi es mas facil.
Eso rompe la cancelacion y los deadlines, y deja sin poder a la capa superior (HTTP/CLI). Como resultado, el cliente se fue, pero tu sigues trabajando, gastas recursos y a veces mantienes bloqueos mas tiempo del necesario. Se arregla facilmente: ctx siempre viene desde arriba y va hacia abajo, sin improvisaciones.

Error n.º 2: Olvidar defer cancel() despues de WithTimeout/WithDeadline.
Aunque parezca que «el timeout lo cancelara todo solo», cancel() es una disciplina para liberar recursos internos (temporizadores, referencias al arbol de contextos). El habito correcto: creas (ctx, cancel) — y en la siguiente linea defer cancel().

Error n.º 3: Poner ctx no como primer parametro, sino en mitad de la firma.
Las funciones empiezan a verse incoherentes, y la revision de codigo se convierte en un juego de «encuentra el contexto». El ecosistema Go esta acostumbrado a la regla: ctx context.Context va primero. No es una ley de la fisica, pero si un «estandar de lectura» muy comodo.

Error n.º 4: No comprobar ctx.Done() en bucles largos (por ejemplo, importacion/recorrido/procesamiento de grandes datos).
Si la operacion consta de mil pasos, pero no miras ctx.Done(), entonces la cancelacion «no funciona» en la practica: el usuario cancelo, y tu sigues leyendo el archivo hasta el final. La solucion es poner puntos de comprobacion dentro del bucle y devolver correctamente ctx.Err() como causa de la parada.

Error n.º 5: Guardar ctx en un campo de la estructura del servicio o del almacenamiento «por si acaso».
Asi mezclas los ciclos de vida de distintas operaciones: una llamada puede cancelar accidentalmente a otra, y los deadlines se vuelven impredecibles. En su lugar, el contexto se pasa como argumento en cada metodo que se refiere a una operacion concreta.

1
Tarea
Go SELF, nivel 70, lección 3
Bloqueada
Tres capas
Tres capas
1
Tarea
Go SELF, nivel 70, lección 3
Bloqueada
Timeout en el límite de la operación
Timeout en el límite de la operación
1
Tarea
Go SELF, nivel 70, lección 3
Bloqueada
Handler de prueba
Handler de prueba
1
Tarea
Go SELF, nivel 70, lección 3
Bloqueada
Importación de CSV
Importación de CSV
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION