Sometimes you get an io.Reader from a library that does not take a context. HTTP response bodies used to be the classic example before http.Client.Timeout could tear them down mid-read; these days it is upstream SDKs, decompressors, and custom network protocols. You want a hard upper bound on how long any single Read can hang.

This is the smallest thing that has actually worked for me. It spawns a goroutine for each read and races it against the context. If the context fires first, the goroutine eventually returns and the buffer is dropped, which is fine as long as the underlying connection gets closed by something else (the usual case).

type deadlineReader struct {
	r   io.Reader
	ctx context.Context
}

func (d deadlineReader) Read(p []byte) (int, error) {
	type result struct {
		n   int
		err error
	}
	ch := make(chan result, 1)
	go func() {
		n, err := d.r.Read(p)
		ch <- result{n, err}
	}()
	select {
	case r := <-ch:
		return r.n, r.err
	case <-d.ctx.Done():
		return 0, d.ctx.Err()
	}
}

func DeadlineReader(ctx context.Context, r io.Reader) io.Reader {
	return deadlineReader{r: r, ctx: ctx}
}

Caveats: the goroutine leaks until the blocked Read returns. For sockets, close the conn from a separate goroutine when the context fires. See also /posts/the-io-reader-pattern-i-keep-getting-wrong/.