Skip to content
skyl

Your First Stream

Four methods, and the two lines people forget.

A Stream has four methods. The loop is three lines. The two lines that are easy to omit are the two that matter most.

You will learn

  • What each of the four methods does
  • Why defer stream.Close() is required even though the reader is context-bound
  • Why stream.Err() after the loop is not optional
  • What is retried on a stream, and what is not

The interface#

type Stream interface {
	Next() bool          // advance; false at end of stream AND on error
	Event() StreamEvent  // valid only after Next returned true
	Err() error          // the error that stopped it, or nil
	Close() error        // release resources; safe to call twice, and early
}
type Stream interface {
	Next() bool          // advance; false at end of stream AND on error
	Event() StreamEvent  // valid only after Next returned true
	Err() error          // the error that stopped it, or nil
	Close() error        // release resources; safe to call twice, and early
}

The loop#

stream.goCompiles
stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()          // ← line one

for stream.Next() {
	ev := stream.Event()
	switch ev.Type {
	case skyl.EventTextDelta:
		fmt.Print(ev.Text)
	case skyl.EventDone:
		if ev.Usage != nil {
			log.Printf("%d tokens", ev.Usage.TotalTokens())
		}
	}
}

return stream.Err()           // ← line two
stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()          // ← line one

for stream.Next() {
	ev := stream.Event()
	switch ev.Type {
	case skyl.EventTextDelta:
		fmt.Print(ev.Text)
	case skyl.EventDone:
		if ev.Usage != nil {
			log.Printf("%d tokens", ev.Usage.TotalTokens())
		}
	}
}

return stream.Err()           // ← line two

Line one: defer Close#

Close is safe to call more than once, and safe to call before the stream is exhausted, so defer is always correct.

Line two: check Err#

What is retried#

Only the handshake. Once bytes are flowing, a mid-stream failure surfaces through Err() rather than being retried.

Deep diveWhy not retry a stream that fails halfway?

Because you have already seen output. Replaying a partially consumed response would duplicate the text the caller printed — a user watching tokens appear would see the answer restart mid-sentence.

The alternative, buffering everything and only emitting once complete, defeats the purpose of streaming.

So Client.Stream retries the initial call — a 429 or a 503 on the handshake is retried with backoff, exactly like Complete — and hands everything after that to you. If your use case can tolerate a restart, you can retry at your own level, where you know whether output has been shown.

A stream is not concurrency-safe#

Client and every adapter are safe for concurrent use. A Stream is not. One stream, one consuming goroutine. If you need to fan the events out, read them in one goroutine and publish onto a channel yourself.

Recap

  • Four methods: Next, Event, Err, Close.
  • defer stream.Close() — abandoning is safe, but Close makes release prompt.
  • Always check stream.Err()Next returning false does not mean success.
  • Only the handshake is retried; mid-stream failures surface through Err.
  • One stream, one goroutine. Client is concurrency-safe; Stream is not.

Try out some challenges

Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.

Stream with a first-token deadline

Give the model 5 seconds to produce its first token, then let it take as long as it needs for the rest.

Show hint

You cannot use one context for both. Cancel a timer when the first delta arrives.

Show solution
goCompiles
ctx, cancel := context.WithCancel(ctx)
defer cancel()

stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()

// Cancel the whole stream if nothing arrives within the deadline.
firstToken := make(chan struct{})
go func() {
	select {
	case <-firstToken:
	case <-time.After(5 * time.Second):
		cancel()
	case <-ctx.Done():
	}
}()

var seen bool
for stream.Next() {
	if !seen {
		close(firstToken)
		seen = true
	}
	if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
		fmt.Print(ev.Text)
	}
}
return stream.Err()
ctx, cancel := context.WithCancel(ctx)
defer cancel()

stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()

// Cancel the whole stream if nothing arrives within the deadline.
firstToken := make(chan struct{})
go func() {
	select {
	case <-firstToken:
	case <-time.After(5 * time.Second):
		cancel()
	case <-ctx.Done():
	}
}()

var seen bool
for stream.Next() {
	if !seen {
		close(firstToken)
		seen = true
	}
	if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
		fmt.Print(ev.Text)
	}
}
return stream.Err()

The ctx.Done() case in the select stops the goroutine leaking when the stream ends normally — which matters, because this pattern runs once per request.

Edit this page on GitHub