DEV Community

Taylor Wang
Taylor Wang

Posted on

A Truncated Stream Can Still Look Like a Finished Answer: An Illustrative Completeness Contract

You ask a model endpoint for a summary, the text arrives piece by piece, the connection closes, and your handler writes the result to storage. Nothing in that sequence proves the answer was complete, because a dropped connection and a finished answer both end with the same quiet EOF. Truncation is one of the least visible failure modes of streamed responses: no exception is raised, no retry is triggered, and the shortened text usually still reads like a plausible answer. This article describes a client-side completeness contract plus an illustrative test plan you can adapt, with clearly labeled limits on what such a check can and cannot prove.

Why a clean EOF is not evidence of completion

Streaming APIs deliver content in fragments, so completeness is a property of the protocol conversation, not of your code reaching the end of a loop. Several signals get conflated in practice, and separating them is the first step toward an honest check.

  • Transport close: the socket or HTTP response ended. This says nothing about whether the producer finished its work.
  • Absence of an exception: your client saw no error. Proxies, idle timeouts, and truncated payloads can all terminate cleanly.
  • HTTP 200: the request was accepted. Status codes describe the start of the exchange, not its ending.
  • Terminal sentinel: many OpenAI-compatible streaming formats end with a sentinel such as [DONE], and the final chunk usually carries a finish_reason. Confirm the exact shape against the specific provider's API reference before depending on it, since this varies.

A useful mental model: everything you can verify on the client is a necessary condition, never a sufficient one. You cannot prove the server finished; you can only prove that the signals you require actually arrived.

The completeness contract

Before writing any code, decide which conditions make a response acceptable in your context. A workable default set looks like this:

  1. The terminal sentinel was observed, not merely assumed.
  2. A finish_reason was seen and belongs to an allow-list you chose deliberately.
  3. The accumulated body passes your own structural checks (JSON parse, required keys, size floor).
  4. The verdict, the reason, and a content hash are recorded together for later correlation.

The code below is illustrative and was not executed or tested against any provider. It shows the shape of the check, not a production-hardened client.

# Illustrative only: adapt the transport and the field names to your provider.
import json, hashlib

ALLOWED_FINISH = {'stop'}  # 'length' means the answer was cut on purpose; treat it as you see fit.

def read_stream(resp, expect_json=False):
    sentinel_seen = False
    finish_reason = None
    parts = []

    for raw in resp.iter_lines(decode_unicode=True):
        if not raw:
            continue
        line = raw.strip()
        if not line.startswith('data:'):
            continue  # comment lines, event names, and ids are ignored on purpose
        payload = line[len('data:'):].strip()
        if payload == '[DONE]':
            sentinel_seen = True
            break
        try:
            chunk = json.loads(payload)
        except json.JSONDecodeError:
            return None, {'ok': False, 'why': 'unparsable_chunk', 'excerpt': payload[:120]}
        for choice in chunk.get('choices', []):
            delta = choice.get('delta', {}).get('content')
            if delta:
                parts.append(delta)
            if choice.get('finish_reason'):
                finish_reason = choice['finish_reason']

    text = ''.join(parts)
    verdict = {
        'ok': True,
        'sentinel': sentinel_seen,
        'finish_reason': finish_reason,
        'chars': len(text),
        'sha256': hashlib.sha256(text.encode('utf-8')).hexdigest(),
    }
    if not sentinel_seen:
        verdict.update(ok=False, why='no_sentinel')
    elif finish_reason not in ALLOWED_FINISH:
        verdict.update(ok=False, why='finish_reason=' + str(finish_reason))
    elif expect_json:
        try:
            json.loads(text)
        except json.JSONDecodeError:
            verdict.update(ok=False, why='body_not_json')
    return text, verdict
Enter fullscreen mode Exit fullscreen mode

Two details that are easy to get wrong

The hash is a correlation tool, not a determinism test. Identical inputs do not guarantee identical model output, so a hash mismatch between two runs proves nothing by itself; it only helps you match a stored record to a captured stream.

A finish_reason of length is a policy decision rather than a bug in your client. If your downstream consumer needs complete sentences or valid JSON, a length-capped answer must be treated as incomplete even though the protocol ended normally.

Illustrative test plan: what to try, observe, and verify

The point of a test plan here is to confirm your validator fails loudly for the right reasons. Treat each row as something to script against a local stub first, then against the real endpoint in a non-critical path.

Fault to inject How to inject it Observe Required verdict
Mid-stream disconnect A local proxy that closes the socket after N bytes EOF with no sentinel ok=False, why=no_sentinel
Silent body trim Rewrite the response to drop trailing chunks Short text, no error raised ok=False, why=no_sentinel
Length cap Set a very small output-token limit A valid sentinel plus a length finish reason ok=False per your policy
Malformed fragment Replay a recorded stream with one chunk corrupted Parse failure at that fragment ok=False, why=unparsable_chunk
Structured answer cut short Truncate JSON mid-object Partial braces ok=False, why=body_not_json

For each run, write down three things in the same log record: the verdict, the raw reason string, and the byte count actually received. Without the byte count you cannot distinguish a genuinely short answer from a stream that stopped early.

From observation to a decision

A validator that only logs is half a tool. Decide in advance what each outcome means for your workflow, so the handling logic does not get invented under pressure.

  • Complete and structurally valid: accept and store, with the verdict attached to the record.
  • No sentinel, partial body: discard the partial text and retry only if the operation is safe to repeat. Use the same request key so a duplicate cannot be silently double-applied.
  • Finish reason outside the allow-list: do not retry blindly; surface the case, because repeating a length-capped call usually reproduces the same cap.
  • Unparsable fragment: fail the whole stream. A partially parsed answer mixed with guessed content is worse than no answer.

Where a free model endpoint and a free server fit in this workflow

The verification above is client-side, which makes it portable: you can exercise it against any streaming endpoint without changing the contract. A free model access tier combined with a free server option is a reasonable place to build and rehearse these checks, because the artifact you are producing is a validator and a log format, not a production capacity plan.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Keep the limitations in view when you do this. Availability of free tiers is operator-supplied and can change without notice, so nothing here should sit on a critical path with uptime expectations. Free environments may also differ from paid ones in ways you cannot see from the client, which is precisely why the contract verifies wire-level signals rather than assuming behavior.

Who should not use this approach

  • Teams whose stream format is already validated by an SDK with documented termination guarantees; duplicating the check adds noise.
  • Workflows where a truncated answer is harmless and cheap to regenerate, such as exploratory drafts a human will read anyway.
  • Systems that need exactly-once side effects: a client-side completeness check does not replace server-side idempotency or transactional boundaries.

Start by picking one call site, write down the four acceptance conditions, and confirm that a deliberately broken stub actually trips your validator. If the validator cannot fail on demand, it is not yet evidence of anything.

Top comments (0)