This guide is for developers who need to read a large result set out of the MPT API in one pass. It covers when to stream instead of paging, what the platform guarantees about a stream, and the obligations a consumer must meet to read one correctly.
For installation, client construction, and the general sync and async patterns, see usage.md. For where the streaming mixins and exceptions sit in the codebase, see architecture.md.
7.0.0 published the platform streaming read as stream(), taking a name that had meant the
endpoint JSONL download since 6.x. Because CollectionMixin inherits the streaming mixin, a
6.x caller's stream() silently became the streaming read: same name, same import, different
request and different results. This release separates the two names again.
| 7.0.0 | now | |
|---|---|---|
stream() |
stream_snapshot() |
the platform streaming read, opted into with MPT-Streaming |
stream_jsonl() |
stream() |
the endpoint JSONL download, unchanged since 6.x |
stream(stream_format=StreamFormat.JSON) |
— | the envelope encoding is held back; see The Wire Format |
Upgrading from 6.x needs no change. Upgrading from 7.0.0 means renaming those two call sites.
stream_snapshot() is a transitional name: the next major version gives stream() back to
the streaming read and renames the JSONL download, so the two contracts swap labels exactly
once more, in a release that says so.
Both read a whole collection. They differ in how the platform produces the result and in what the client can guarantee about it.
iterate() |
stream_snapshot() |
|
|---|---|---|
| Requests | One request per page | One request for the whole export |
| Read mode | Regular paged read | Streaming mode, opted into with MPT-Streaming: true |
| Membership | Re-evaluated on every page | Fixed once, when the stream opens |
| Response format | application/json page envelope |
application/jsonl, one record per line |
| Completeness | Not verifiable | Verified against MPT-Item-Count |
| Deleted members | Absent from later pages | Emitted as a DeletionStub |
| Recovery from failure | Re-fetch the failed page | Restart the whole export |
| Peak memory | One page | One record |
Use iterate() when you want the collection as it is right now and you will consume all of
it promptly: it is the plain paged read, and a failure costs one page. Use it also for
endpoints that do not stream, and as the fallback when streaming is refused.
Use stream_snapshot() when you need a consistent export rather than a live read — a nightly sync, a
reconciliation job, a bulk load into another system. Streaming asks the platform for a
point-in-time export, so the result set does not shift underneath you while you read it, and
the client can tell you whether you received all of it.
stream_snapshot() is not a faster iterate() for small reads. It costs the platform a key scan
before the first byte and it costs you the three obligations below. For a few hundred
records, page.
CollectionMixin and AsyncCollectionMixin inherit StreamingMixin and
AsyncStreamingMixin, so stream_snapshot() is available on every collection service without
per-service wiring:
from mpt_api_client import BearerTokenAuthentication, MPTClient, RQLQuery
client = MPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)
for order in client.commerce.orders.filter(RQLQuery(status="Processing")).stream_snapshot():
print(order.id)Streaming mode is a property of the request, not of the service. The route is the ordinary
collection route, so filter(), order_by() and select() chain before stream_snapshot() exactly
as they do before iterate(); the client turns the read into a stream by sending the
MPT-Streaming header.
You can assume a standard collection endpoint streams: the platform's shared framework
provides streaming mode on its standard read controller, and every list endpoint is expected
to support it. Two cases are the exception rather than the rule. A hand-written list action —
one with a mandatory scoping filter, or one that already assigns a media type its own meaning
— opts in explicitly. And an endpoint that has not picked up the rollout answers 501.
That 501 is an ordinary, expected answer rather than a fault, surfaced as
MPTStreamingNotSupportedError. Handle it by falling back to iterate(), not by checking
endpoints up front:
from mpt_api_client.exceptions import MPTStreamingNotSupportedError
try:
records = list(client.commerce.orders.stream_snapshot())
except MPTStreamingNotSupportedError:
records = list(client.commerce.orders.iterate())The two methods look alike and mean different things.
stream_snapshot() |
stream() |
|
|---|---|---|
| Contract | The platform streaming read mode | An endpoint's own JSONL download |
MPT-Streaming header |
Sent, and the response must confirm it | Not sent |
| Completeness check | Yes | No |
| Yields | Models or DeletionStub objects |
Models only |
| Availability | Every collection service | Composed explicitly, today only by billing statement charges |
A service can carry both, and billing statement charges does: stream() is its JSONL
download, while stream_snapshot() is the streaming-mode read it inherits with every other
collection service. Reach for stream() only when you specifically want that endpoint's
JSONL contract; for everything else stream_snapshot() is the streaming read.
stream() is the name the JSONL download has carried since 6.x, and it keeps it. In the
next major version stream_snapshot() takes the name back for the streaming read and the
JSONL download is renamed, so the two contracts swap labels exactly once, in a release that
says so.
The line grammar is shared: in both methods a record line must decode to a JSON object,
and a line holding any other valid JSON value — a bare scalar, an array, null — raises
json.JSONDecodeError rather than producing a bogus model.
stream_snapshot() holds one record at a time. The response body is parsed as it arrives and each
record is deserialized, yielded, and dropped, so peak memory is set by the largest single
record rather than by the size of the export. A ten-million-record stream costs the same as a
ten-record one. The body is tokenized incrementally rather than buffered.
Time scales the same way. A record that spans many body chunks is assembled once, when its line ends, so the cost of a record is linear in its own length: doubling the record size doubles the work rather than quadrupling it.
The buffering paths, for contrast:
iterate()holds one page. It buffers each page response in full, deserializes it into aModelCollection, and yields from that before fetching the next — so peak memory isbatch_sizerecords, 100 by default.fetch_page()andfetch_one()buffer a single response and return it whole.- Any call that materializes the iterator — wrapping a stream in
list(), a comprehension, asorted()— buffers the entire result set and gives up the bound. That is a fine choice when you know the result is small, and it is the reason the short examples in this guide use it, but it is a decision to make on purpose rather than by accident.
The flat profile is a property of the loop, not of the method. Keep the per-record work
inside the for body — write, upsert, aggregate — and the whole export stays bounded.
The bound above is asserted, not assumed: tests/e2e/streaming/ fails if the allocation peak
scales with the record count, or if buffering the same export stops being markedly more
expensive than streaming it. Proving that costs four live reads — a 500-record warmup, a
2,000-record export, a 20,000-record export, and that same export buffered — so 42,500
records per case and roughly 85,000 across the sync and async pair.
See Streaming Memory Coverage.
The platform builds an export in two phases. Phase one scans keys and fixes the membership of the export; phase two reads the records for those keys in batches and writes them to the response body.
Two consequences follow, and both are contract-conformant behaviour that will otherwise read as a bug:
- Membership is fixed when the stream opens. Records created after that point are not in the export, and rows whose filter columns change after that point are not ejected from it. A stream is a point-in-time export, not a live paged read.
- Record content is the committed state at batch read time, so a record's contents can postdate the moment membership was fixed. There is no cross-record point-in-time consistency: record A can be newer than record B in the same export.
A record whose access is revoked mid-export still streams, because security-context filters are applied when membership is fixed and are not re-applied per batch.
The longer an export runs over frequently written data, the wider the drift between membership and content: expect more deletion stubs and more post-snapshot content on a long export than on a short one.
The wire format is fixed: stream_snapshot() always requests application/jsonl, one
record object per line with no envelope. The MPT-Streaming header selects streaming mode,
and Accept is not a choice the caller makes.
stream_snapshot() rejects a response whose Content-Type names a different media type,
before reading its body. That guard catches a proxy that ignores Accept and labels the
body honestly. It does not catch a body mislabeled as application/jsonl: a {$meta, data}
envelope on one line is a JSON object, so the line reader accepts it as one record, and only
the count check against MPT-Item-Count will notice.
The body is parsed as it arrives, so the memory bound described above holds.
Keep-alives are blank lines and are invisible: whitespace means the four characters RFC 8259 fixes — space, tab, line feed and carriage return — so a line is a keep-alive only when there is nothing else on it. It never reaches your loop and never counts as a record.
Record boundaries are newlines alone: a record ends at a line feed, optionally preceded by a
carriage return, and nothing else ends one. The last record is the one exception, and ends
at the end of the body whether or not a final line feed arrives. Inside a JSON string value every character from
%x20 up is legal unescaped except " and \, so U+0085, U+00A0, U+2028, U+2029, U+3000
and U+FEFF arrive whole in your text fields. Outside a value none of them is whitespace or a
separator, so each of these is a malformed body rather than something the reader absorbs, and
raises json.JSONDecodeError:
- a line carrying only one of them, which is not a keep-alive and does count towards
MPT-Item-Count - a record padded with one of them outside its value
- a body that frames its records with a lone carriage return, or with U+001E — a separator
of
application/json-seq, a different media type from theapplication/jsonlthis client requests
A single UTF-8 byte order mark opening the body is dropped — the same tolerance json.loads
gives the paged path — so a BOM-emitting producer parses identically everywhere.
A progress receiver gets the declared MPT-Item-Count through set_total_items, exactly
once, before the first record arrives, so a progress report can render a percentage of a
streamed export.
Note: the
{$meta, data}envelope encoding and the incremental parser that reads it are held back for the next major version, wherestream_snapshot()becomesstream()again.StreamFormatremains a single-member enum so re-adding the envelope costs no change to any public name.
limit selects between the whole snapshot and a bounded prefix of it:
limit |
Meaning |
|---|---|
| absent (default) | The full snapshot |
-1 |
The same thing, stated explicitly |
N |
The first N records of the stream order |
Under a bounded limit=N, the count the response declares is the capped count,
K = min(matches, N) — not the uncapped number of matches: MPT-Item-Count declares K.
The completeness check compares against that capped value, so a bounded export verifies
exactly like a full one.
for order in client.commerce.orders.order_by("-audit.created.at").stream_snapshot(limit=100_000):
print(order.id)stream_snapshot() also accepts offset. Pagination inputs are sent exactly as given and are never
checked locally, because the server owns their validation: it currently rejects offset in
streaming mode with 400, and support for it is scheduled. Passing through is correct either
way, so no client release is coupled to that change.
Streaming trades the safety of paging for a single-pass export. These three checks are what you take on in exchange. Each prevents a failure that is silent without it.
Prevents: processing a truncated export as if it were the whole result set.
Streaming mode commits the MPT-Item-Count response header together with the status: the
number of records the stream will carry. It is the contract's only completeness signal. It
is declared with the headers, before the data, so it is a total to check the body against
once the body ends, not a claim that the body arrived.
stream_snapshot() performs this check for you. It reads the declared count before yielding the first
record and compares it with the number of raw records consumed when the body ends —
consumed, not yielded, because skip_deleted filtering happens after this accounting:
- No usable count declared →
MPTStreamingItemCountMissingError, raised before the body is read, so no partial data is consumed. - Count mismatch on a fully consumed body →
MPTStreamingIncompleteError.
A mismatch means the stream terminated gracefully but did not carry what it promised — an intermediary swallowed part of the body, or the export was cut short. The records you received are not a valid subset to keep, because you cannot tell which ones are missing. Discard them and re-run the export.
Closing the iterator early does not raise. The check applies only to a stream consumed to
the end, so break-ing out of the loop on purpose is not reported as an incomplete export:
for order in client.commerce.orders.stream_snapshot():
if order.id == "ORD-0000-0001":
break # deliberate early exit, no completeness checkOn the async client a deliberate early exit also has to close the stream, or the response stays open until the event loop gets around to finalizing the generator. See Leaving An Async Stream Early.
The count does not survive persisting the payload. If you write the raw records to storage and verify them later, store the expected count alongside them — once the response is gone, the export's own completeness signal is gone with it.
The supported way to capture it is a progress receiver: stream_snapshot() calls set_total_items
with the declared MPT-Item-Count before the first record, so the count is in hand before
any record is written. stream_snapshot() still yields records, not headers —
there is no need to drop to client.http_client.stream(...) just to read the header.
Prevents: overwriting a live stored record with nulls.
A member of the snapshot whose row is hard-deleted before phase two reaches it is still a
member of the export, so the platform emits a deletion stub in its place, marked with
$meta.deleted — the platform's metadata channel for the signal, and the name the API
contract uses for it:
{"id": "ORD-1234-5678", "$meta": {"deleted": true}}Only id is guaranteed on a stub. No other property of the deleted row is carried. A
truthy deleted marker is what identifies a stub; a record with no $meta, no deleted
key, or a falsy one is data. In practice the platform omits $meta entirely on a normal
record, so those cases are defensive rather than expected. A stub that carries no string
id breaks the one guarantee the contract makes, so stream_snapshot() raises TypeError rather
than yielding a stub that identifies nothing.
stream_snapshot() yields these as DeletionStub, never as a model, so the object cannot be mistaken
for a record by code that expects one. Deserializing a stub as a model would produce an
instance whose every declared field is None — indistinguishable from a record whose values
really are unset — and a sync job writing that back would overwrite the stored record with
nulls. Branch on the type before ingesting the object; there is nothing else on a stub to
inspect:
import uuid
from mpt_api_client.models import DeletionStub
# A fresh key per attempt. A retry opens a new snapshot, so it must never reuse one.
attempt_id = uuid.uuid4().hex
try:
for result in client.commerce.orders.stream_snapshot():
if isinstance(result, DeletionStub):
stage_delete(attempt_id, result.id)
else:
stage_upsert(attempt_id, result)
# Reached only once stream_snapshot() has verified the record count against MPT-Item-Count.
promote(attempt_id)
except Exception:
discard(attempt_id)
raiseNote what the loop does not do: it stages rather than writes. stream_snapshot() yields records
before it can verify the count, so applying each record as it arrives leaves partial local
state behind on a truncated export — the one thing
Restart, Do Not Resume says must not survive a failed attempt.
Stage under an attempt key and promote after the loop, which is reached only on a verified
export.
Catch broadly rather than on MPTStreamingError, because not every failure that can strand a
staged attempt is one. A malformed body raises json.JSONDecodeError, and your own staging
code can fail too; either way promotion is skipped, and without the cleanup the durable
staging survives indefinitely. The raise matters as much as the discard: the caller still
has to learn the export failed.
Stage somewhere durable — a staging table, a temp file, a keyed batch — not a Python list. Accumulating the export in memory to promote it later gives up the bound that made streaming worth using, which is the trap this pattern invites.
Three properties of stubs matter for correctness:
- Stubs are counted, not filtered. Every member of the export accounts for exactly one
record, stubs included, so a stub counts towards
MPT-Item-Count. Do not drop stubs before the completeness check — a complete export would read as short. The safe way to drop them isskip_deleted, which filters after that accounting has been fed, so a withheld stub still counts. - A stub is not a
DELETEDstatus. ADELETEDstatus is a domain state on a full, existing record, and stays a model. A stub marks a row that no longer exists at all and carries no state. Conflating them either loses a deletion or discards a live record. - A stub does not satisfy a record schema. If you validate incoming payloads against a
strict schema, branch on
DeletionStubfirst and skip the record validation for stubs, rather than loosening the schema for records too.
Anything that reads a field other than id needs this branch. A loop that only reads
order.id is safe as written, because every streamed object carries an id — but it is one
edit away from not being safe.
A consumer that never ingests deletions — read-only analytics, an ad-hoc export — gains
nothing from the branch: it would drop the stubs and move on. Declare that with the
keyword-only skip_deleted flag instead of writing a branch that discards:
for order in client.commerce.orders.stream_snapshot(skip_deleted=True):
print(order.id)The flag is typed with overloads, so a type checker resolves stream_snapshot(skip_deleted=True) to
Iterator[Model] — AsyncIterator[Model] on the async service — and an opted-out consumer
carries no union type through its own signatures. The default call keeps
Iterator[Model | DeletionStub] and the branch it forces.
Filtering happens at yield time, after the client's own bookkeeping, so the guarantees above survive it:
- Completeness still counts the raw records ahead of the filter and still compares them with
MPT-Item-Countonce the body is consumed. A short stream raisesMPTStreamingIncompleteErroreither way. - A
progressreceiver still getsitem_processedfor every record, withheld stubs included, because the declared total counts stubs — a report fed only visible records would never reach 100%.
One consequence to plan for: under skip_deleted=True the number of objects your loop sees
no longer matches MPT-Item-Count when the snapshot contains stubs. Do not compare your own
count against the header in this mode. The client has already verified the full snapshot
arrived, which is the check that matters.
Opting out is a statement that deletions are irrelevant to this consumer, not a shortcut. A job that mirrors the collection must keep the default and branch, or members deleted upstream survive locally forever — the same silent divergence this obligation exists to prevent.
Prevents: splicing two different snapshots into one result set.
Resume is a contract non-goal. A retry is a new request, which opens a new membership snapshot, so records from a failed attempt cannot be continued or appended to a later one. Discard everything the failed attempt produced and restart from scratch:
from mpt_api_client.exceptions import (
MPTStreamingIncompleteError,
MPTStreamingTruncatedError,
)
try:
records = list(client.commerce.orders.stream_snapshot())
except (MPTStreamingIncompleteError, MPTStreamingTruncatedError):
records = list(client.commerce.orders.stream_snapshot()) # a new snapshot, not a continuationMPTStreamingTruncatedError is how a mid-stream failure arrives: the API signals an internal
failure by aborting the connection without completing the HTTP message, so the transport
failure is the failure signal. It is raised once the response has opened — usually after
records have reached your loop, but equally when the body dies before the first one. It is
not retry exhaustion: transparent retry runs while the response is being opened and cannot
re-request once the body has started, so MPTMaxRetryError stays reserved for a request that
never delivered a body at all.
The same rule covers MPTStreamingIncompleteError: whatever the cause, a failed export is
discarded whole and re-requested, never patched.
If restarting a large export is expensive, write records to a staging area keyed by the attempt and promote it only after the stream completes without raising. That keeps the discard cheap and keeps a failed attempt from reaching the records your consumers read.
This is the failure most likely to be misdiagnosed, because a correctly working export looks exactly like a broken one.
Phase one scans keys before a single byte of the body is written, and the platform's SLO allows that to take up to 60 seconds on a large export. During that time the connection is established and the client is waiting for the response to start.
It is the read timeout, not the connect timeout, that bounds a deferred first byte. A client tuned with a generous connect timeout and a short read timeout will abandon a working export mid-scan and report a timeout that looks like a server failure.
TransportSettings exposes stream_read_timeout for exactly this. A streaming request's
read phase is bounded by the larger of stream_read_timeout and read_timeout, and
stream_read_timeout defaults to 120.0 — enough for the 60s SLO with headroom, where the
regular read_timeout default is not:
from mpt_api_client import BearerTokenAuthentication, MPTClient, TransportSettings
from mpt_api_client.http import HTTPClient
client = MPTClient(
http_client=HTTPClient(
transport=TransportSettings(
base_url="https://api.s1.show/public",
timeout=20.0,
connect_timeout=5.0,
read_timeout=30.0,
stream_read_timeout=180.0,
),
authentication=BearerTokenAuthentication("<token>"),
)
)Three things to know about it:
- Because the larger value wins, raising
read_timeoutraises the streaming budget too, and streaming can never end up with the shorter of the two. - It bounds a single read, not the whole export. No total-duration timeout is applied: an export runs for as long as the server keeps sending. Server-side keep-alives count as data and reset the read clock: a blank line is a keep-alive. A line of anything else fails the read instead of extending it.
- Lowering it below the SLO is the trap. If you tune timeouts down for a low-latency service
and set
stream_read_timeoutfrom the same budget as your regular calls, large exports start failing while small ones keep working — which reads as a size-dependent server bug rather than a client setting.
Anything between your client and the platform needs the same treatment, and where the cut falls decides which error you get:
- Before the response headers arrive — a proxy or load balancer whose first-byte timeout
is shorter than the phase-one budget cuts the connection while the key scan is still
running. Nothing has been committed yet, so transparent retry applies and the failure
surfaces as
MPTMaxRetryError, not as a truncated stream. - After the headers arrive — a cut during the body is
MPTStreamingTruncatedError, even if no record reached your loop, because the response had already committed its status.
See Timeouts for the full per-phase timeout model.
All streaming-specific failures derive from MPTStreamingError, so one handler covers them:
| Exception | Raised when |
|---|---|
MPTStreamingNotEnabledError |
The response does not echo MPT-Streaming, so the body is an ordinary paged response |
MPTStreamingFormatMismatchError |
The response Content-Type names a media type other than application/jsonl |
MPTStreamingNotSupportedError |
501 — the resource provides no streaming-capable execution strategy |
MPTStreamingNotAcceptableError |
406 — the requested format cannot be served for this read mode |
MPTStreamingOverCapError |
413 — the result set exceeds the configured MaxExportKeys cap |
MPTStreamingItemCountMissingError |
The response declares no usable MPT-Item-Count, so completeness cannot be verified |
MPTStreamingIncompleteError |
The fully consumed stream carried a different number of records than MPT-Item-Count declared |
MPTStreamingTruncatedError |
The connection was aborted mid-body, so the response ended before the HTTP message completed |
import logging
from mpt_api_client.exceptions import MPTStreamingError
logger = logging.getLogger(__name__)
try:
for order in client.commerce.orders.stream_snapshot():
print(order.id)
except MPTStreamingError as error:
logger.error("Streaming unavailable: %s", error)Split them by what a caller can do about them:
- Request-shape and endpoint-support failures —
MPTStreamingNotEnabledError,MPTStreamingFormatMismatchError,MPTStreamingNotSupportedError,MPTStreamingNotAcceptableError,MPTStreamingOverCapErrorandMPTStreamingItemCountMissingError. Retrying the same call against the same endpoint fails the same way. Change the request, or fall back toiterate(). A406means the endpoint cannot serveapplication/jsonlfor this read mode, so streaming is not available there at all. - Incomplete-export failures —
MPTStreamingIncompleteErrorandMPTStreamingTruncatedError. A retry can succeed, but only as a fresh export; see Restart, Do Not Resume.
MPTStreamingNotEnabledError and MPTStreamingItemCountMissingError are raised before the
body is read, so no partial data is consumed. The three HTTP-backed types also subclass
MPTHttpError, so existing except MPTHttpError handlers keep working and status_code
remains available. Any other HTTP status passes through unchanged. The status decides the
type, so the mapping holds whatever the error body turns out to be: an endpoint that answers
406 or 413 with a bare JSON string instead of problem+json still raises the typed error,
only without the members payload would otherwise carry.
A body the client cannot parse is not a streaming error at all but a json.JSONDecodeError:
a malformed record line, or a line whose JSON is valid but is not an object. A record must be
a JSON object, so a line holding a bare scalar, array, or null is rejected with that typed
decode error rather than surfacing as a crash deeper in record handling. A body cut short
usually loses records before the reader runs out of lines to parse, so a truncated export
normally reports the more precise MPTStreamingIncompleteError instead.
The API answers 413 when the result set is larger than the configured MaxExportKeys cap.
MPTStreamingOverCapError keeps the problem+json body as structured data on payload
rather than flattening it into the message, because the configured cap is the value a caller
acts on:
import logging
from mpt_api_client.exceptions import MPTStreamingOverCapError
logger = logging.getLogger(__name__)
try:
records = list(client.commerce.orders.stream_snapshot())
except MPTStreamingOverCapError as error:
# The body names the configured cap. Read it defensively: payload is {} when the
# response carried no JSON, and the member names are the server's, not the client's.
cap = error.payload.get("maxExportKeys")
logger.error("Export refused (configured cap: %s): %s", cap, error.payload)
# A bounded retry is a different read: the first N of the sort order, not the export.
records = list(client.commerce.orders.stream_snapshot(limit=10_000))payload is an empty mapping when the response carries no JSON body, so treat every member
as optional. The ways forward are the ones the body names: narrow the filter, set an explicit
limit=N, or split the export into key or date ranges.
Two things about that retry are worth being deliberate about:
- A bounded retry does not get you the export.
limit=Nreturns the firstNrecords of the sort order, andMPT-Item-Countreports the cappedK, so the result is complete as a stream but is a prefix of the set you asked for. If you need all of it, narrow the filter or split by key or date range instead — those return the whole set in pieces. - Do not derive the limit from the cap. A limit equal to
maxExportKeysasks for the largest export the server will permit, which is the request that just failed for being too big to be useful. Pick a size the consumer can actually process; the cap tells you the request was refused, not what to ask for next.
The async form is the same contract over an async generator. Consume it inside a coroutine —
a top-level async for is a syntax error:
import asyncio
import uuid
from mpt_api_client import AsyncMPTClient, BearerTokenAuthentication
from mpt_api_client.models import DeletionStub
async def export_orders() -> None:
client = AsyncMPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)
attempt_id = uuid.uuid4().hex
try:
async for result in client.commerce.orders.stream_snapshot():
if isinstance(result, DeletionStub):
await stage_delete(attempt_id, result.id)
else:
await stage_upsert(attempt_id, result)
await promote(attempt_id)
except Exception:
await discard(attempt_id)
raise
asyncio.run(export_orders())Everything above applies unchanged: the same headers, the same completeness check, the same
stubs, the same restart rule. The Async* mixins and an awaited body are the visible
difference; leaving a stream early is the one behavioural one.
A loop that runs to the last record needs nothing extra — the generator closes the response
on its way out. Leaving early is the one place the async form does not behave like the sync
one, so wrap the stream in contextlib.aclosing() whenever the loop can exit before the end:
a break, a return, an exception.
from contextlib import aclosing
async with aclosing(client.commerce.orders.stream_snapshot()) as orders:
async for order in orders:
if order.id == "ORD-0000-0001":
break # the response is released when the async with block exitsWithout the wrapper, the break leaves the generator suspended and still holding the open
response. Python does not close an abandoned async generator when its last reference goes; it
hands it to the event loop's async-generator finalization hook, which runs the aclose() at
a moment nothing in your code controls. Until that happens the HTTP connection stays checked
out of the httpx pool.
In a short script this is invisible. In a long-lived service that breaks out of many streams, the checked-out connections accumulate against the pool limit, and the debt surfaces at loop shutdown as unclosed-response and unfinalized-async-generator warnings — which point at the shutdown rather than at the loop that caused them.
The sync form needs no wrapper on CPython: dropping the last reference to a suspended
generator closes it there and then, so break-ing out of a for releases the response
promptly. contextlib.closing() states the same thing explicitly if you would rather not
rely on refcounting.
Two things the wrapper does not change:
- The completeness check is still skipped. Closing the iterator releases the connection; it does not verify the export. An early exit is not reported as an incomplete stream, as obligation 1 describes.
progressstill sees nocompleted(). The export was abandoned rather than finished, so the receiver ends on the lastitem_processedit was given.
The JSONL stream() is an async generator over an open response too, so an early exit from
one takes the same wrapper.
A long export gives no feedback by default. stream_snapshot() accepts an optional progress
receiver, called once per consumed record — including a stub withheld by skip_deleted —
and once on completion:
from mpt_api_client.models import ConsoleProgress
for order in client.commerce.orders.stream_snapshot(progress=ConsoleProgress()):
print(order.id)set_total_items is called exactly once, with the declared MPT-Item-Count, when the
response headers are verified — before the first record — so ConsoleProgress renders a real
percentage from the start of the export.
The receiver is also where you capture the declared count when the payload is stored for
later verification, as obligation 1
describes. Implement the Progress protocol (or AsyncProgress for the async client) to
route progress somewhere other than the console.
- usage.md: installation, client construction, timeouts, and general usage
- architecture.md: where the streaming mixins and exceptions live
- rql.md: building the filters a stream applies
- e2e_tests.md: the live-API coverage behind the memory bound described above