Client Streaming
Many requests in, one response out
Section titled “Many requests in, one response out”Client streaming flips server streaming around: the client sends a sequence of messages over one open stream, and the server replies with a single response — but only after the client signals it is finished.
It is the natural fit for feeding data into a service: uploading a file in chunks, ingesting a batch of metrics, or sending many records that the server folds into one summary.
sequenceDiagram participant C as Client participant S as Server C->>S: Sample 1 C->>S: Sample 2 C->>S: Sample 3 C->>S: (half-close: done sending) S-->>C: UploadSummary(count: 3, accepted: 3) Note over C,S: server responds once, after the client finishes
The contract
Section titled “The contract”Here the stream keyword goes on the request. The response stays singular:
syntax = "proto3";package metrics.v1;
message Sample { string name = 1; double value = 2;}
message UploadSummary { int32 count = 1; int32 accepted = 2;}
service Ingest { // A stream of Samples, one UploadSummary response. rpc UploadSamples(stream Sample) returns (UploadSummary);}Client: send in a loop, then close
Section titled “Client: send in a loop, then close”The client sends each message, then performs a half-close — it tells the server “I’m done sending” — and waits for the single response.
stream, _ := client.UploadSamples(ctx)for _, s := range samples { if err := stream.Send(s); err != nil { log.Fatal(err) }}summary, err := stream.CloseAndRecv() // half-close, then get the replyif err != nil { log.Fatal(err)}fmt.Println(summary.Accepted)def sample_iter(): for s in samples: yield metrics_pb2.Sample(name=s.name, value=s.value)
summary = client.UploadSamples(sample_iter()) # returns after the iterator is exhaustedprint(summary.accepted)const call = client.uploadSamples((err, summary: UploadSummary) => { if (err) throw err; console.log(summary.accepted);});for (const s of samples) call.write(s);call.end(); // half-close: done sending, now wait for the responseServer: fold the stream into one answer
Section titled “Server: fold the stream into one answer”The server reads every message, accumulates state, and returns a single response once the client half-closes.
func (s *server) UploadSamples(stream metricsv1.Ingest_UploadSamplesServer) error { var count, accepted int32 for { sample, err := stream.Recv() if err == io.EOF { // client is done — send the single response return stream.SendAndClose(&metricsv1.UploadSummary{Count: count, Accepted: accepted}) } if err != nil { return err } count++ if sample.Value >= 0 { accepted++ } }}def UploadSamples(self, request_iterator, context): count = accepted = 0 for sample in request_iterator: count += 1 if sample.value >= 0: accepted += 1 return metrics_pb2.UploadSummary(count=count, accepted=accepted)function uploadSamples(call: ServerReadableStream<Sample, UploadSummary>, cb) { let count = 0, accepted = 0; call.on('data', (s: Sample) => { count++; if (s.value >= 0) accepted++; }); call.on('end', () => cb(null, { count, accepted }));}Design notes
Section titled “Design notes”- The response comes only after the half-close. The client cannot expect a reply mid-stream; the single response is the signal that the whole upload was processed.
- Bound the total. A client can stream forever; the server should cap how much it will accept (message count or size) to avoid unbounded memory or work.
- Order is preserved, so the server can rely on receiving samples in send order.
- Either side can end early with an error. If the server rejects the batch partway (e.g. quota exceeded), it can return an error status instead of the summary — the client’s
CloseAndRecvsurfaces it.