Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[0.2.1] - 2026-07-21¶
Added¶
- Error replies now carry an explicit
__istos_errordiscriminator, so a failure is recognised by one field rather than guessed from body shape. It is authoritative in both directions:trueis an error,falsemarks a normal result even when the success value legitimately carrieserror/code/messagekeys — closing the false-positive where such a success was misread as a failure. The change is backward-additive: when the field is absent (an older responder, or a client in another language), detection falls back to the legacy rule of a dict carrying all three oferror,codeandmessage, so nothing on the wire breaks. New helperistos.reply_err(...)builds a stamped envelope a handler canreturninstead of raising.
[0.2.0] - 2026-07-17¶
Changed¶
-
Breaking. Capability manifests are served per service at
.istos/capabilities/<service_name>, andapp.discover_capabilities()returns every service's manifest keyed by name. The bare.istos/capabilitiesis unchanged and still answers, but it cannot answer for a fleet: every node serves it on the same key and@handledeclares its queryablecomplete=True, so Zenoh asks exactly one node and never reaches the rest. A wildcard did not help either, since the key was identical everywhere. Distinct keys are what Zenoh fans out over, the same way*/healthreachesa/healthandb/health. Services sharing a name share a key and one of them answers, so name them distinctly; replicas of one service are meant to share it, as the manifest describes the service rather than the process. -
Breaking.
query_onceand@queryraise when the responder failed, instead of handing back the error payload as data. A handler that raises replies with anErrorResponsepayload, which is an ordinary dict on the wire, soreply.get("clients")on a failed reply returned[]and an outage could not be told apart from an empty result.stream_queryandopen_channelalready raised; queries now match them. Thecodepicks the class, soexcept NotFoundError(alsoUnauthorizedError,ForbiddenError,RateLimitError) works across a hop; any other code arrives asIstosErrorwith itscodekept, andcorrelation_idcomes with it for matching the responder's log line. Callers that tested for errors by hand can drop that code:# before reply = await app.query_once("clients/get", id="acme") if reply and reply.get("code") == "not_found": return None # after try: return await app.query_once("clients/get", id="acme") except NotFoundError: return NoneMulti-reply queries (several responders on one key) are unchanged: the list holds whatever each responder said, error envelopes included. Use
is_error_payloadon each. -
Breaking. The queue calls check the same envelope, at the single Zenoh get they all share.
app.result(...)reported{"state": "unknown"}for a refusal,app.dead_letters(...)reported[], andapp.enqueue(...)raisedUnauthorizedErrorwhatever the owner actually sent. All three now raise the owner's error. A worker whose token the owner rejects died on aKeyError; it now logs the refusal and keeps polling. -
Breaking. The queue owner's refusal reply is a standard
ErrorResponseenvelope. It carriederrorandcodebut nomessage, which is why callers had to hand-roll the check. Itserrorfield now holds the code, as everywhere else, so a caller readingreply["error"]seesunauthorizedrather than the sentence. -
Breaking.
retry=no longer retries an error the responder will only repeat.not_found,unauthorized,forbiddenandvalidation_errorfail on the first attempt;rate_limit_exceeded, 5xx and transport faults retry as before. This applies to@handleretry as well, so a handler raisingNotFoundErrorunderretry=3runs once.is_retryable(exc)is the rule. -
IstosErrortakescorrelation_idas a constructor argument instead of having it attached afterwards. -
CODE_TO_STATUSandDEFAULT_ERROR_STATUSmoved fromistos.http.gatewaytoistos.errors, re-exported from their old home. The status decides retryability and is not on the wire, so an error rebuilt from a reply recovers it from the code rather than defaulting to 500.
Added¶
query_once(..., consolidate_replies=False)for wildcard fan-out. Zenoh consolidates replies by default and drops some even when the responders answered on different keys, so a*/healthsweep could silently return a subset.discover_capabilities()uses it.is_error_payload(reply),error_from_payload(reply)andis_retryable(exc)on the top-levelistosnamespace, for replies you decode yourself and for multi-reply results.is_error_payloadpreviously lived inistos.http.gateway, which put an HTTP import on the fabric path; it is re-exported there.
Fixed¶
- The RPC guide said that multiple handlers on one key produce a list. They do not.
@handledeclares its queryablecomplete=True, meaning one responder can answer the whole key, so Zenoh asks exactly one and the others are never asked; the default reply consolidation collapses same-key replies in any case. Fan-out needs distinct keys, as in*/healthovera/healthandb/health, which does work. The same applies to.istos/capabilities, which every node serves on the identical key: a query returns one arbitrary node's manifest, and a wildcard does not help. It is a self-description endpoint, not fleet discovery. Namespacing the manifest per service would fix it and is a wire change, so it is not in 0.1.x.
Known limits¶
- An error is recognised by shape, not by the transport: a reply is an error if it is a dict carrying
error,codeandmessage. A handler that legitimately returns all three is read as a failure. The structural fix is out-of-band signalling, either Zenoh'sQuery.reply_err()or a flag in the request envelope Istos already attaches, and both change the wire protocol. Addressed in [Unreleased] — an in-band__istos_errordiscriminator (backward-additive, no wire break) rather than out-of-band signalling.
[0.1.2] - 2026-07-15¶
Fixed¶
@streamnow ends its Zenoh query as soon as the generator finishes, instead of leaving it to be garbage-collected. The consumer'sget()returns only once every matching queryable has finished, so the lingering query left everystream_query/ SSE client waiting out the full timeout after the last chunk — a browserEventSourcesat idle forhttp_timeout_s(60s by default) on a stream that had already completed, andevent: endarrived only when the timeout expired. Streams now close immediately; the test suite dropped from 260s to 67s as a side effect.
Added¶
examples/fable-workflowgained--serve: the loop behindGET /runas SSE, so it can be driven withcurl -Ninstead of the CLI.
[0.1.1] - 2026-07-15¶
Added¶
JobContext— a worker that names actxparameter is handed the delivery'sjob_id,queue,attempt,max_attemptsandlast_error(the previous attempt's failure), plusis_retry/is_last_attempt. Opt-in and additive: a worker withoutctxis unchanged. Resolves alongsideDepends(...), which still wins on the same name.examples/fable-workflow— the Fable Method as four cooperating nodes over work queues, driven by a local LLM.
Changed¶
- The queue owner's claim reply now carries
last_error, so a redelivered job can tell its worker why the last attempt failed. Backward compatible in both directions: an older worker ignores the field, and a newer worker against an older owner seeslast_error=None.
[0.1.0] - 2026-07¶
Added¶
@handle/@query— 1-to-1 RPC with selector → function args@stream/stream_query— chunked RPC replies@channel/open_channel/ChannelClient— duplex agent sessions (WebSocket viaws=, fabric via Zenoh;?conversation_id=resume on WS)@stream_client/@channel_client— declarative clients (mirrors@query)@channel(durable=True)+SessionStore— resumable conversations over the app storage ledger@publish/@subscribe— 1-to-many events;durable=Truebrokerless replay@publish(persist="s3://…")/app.persist(...)/app.replay(...)(istos[s3])- Work queues —
app.queue(...)owner,@app.worker(...)competing consumers,app.enqueue(...),app.dead_letters(...). Lease-based redelivery, exponential-backoff retries, dead-letter, delayed jobs (delay_s) + priorities, result backend (keep_results/app.result(...)), periodic scheduling (app.schedule(...), interval orcron=), workflows (app.chain/app.group/app.chord), owner failover (ha=True, liveliness leader election), O(log n) heap store, push-nudge delivery; durable via the storage plugin - HTTP gateway (
http_port) for@handle(..., http=…)and@stream(..., http=…)(SSE) Istos(enable_mcp=True)— MCP JSON-RPC tools from@handleendpoints (/mcp; batch + 202 notifications)istos.http.asgi.lifespan/Istos.serving(serve_http=…)— co-host the mesh inside FastAPI/StarletteGET /livez,/readyz,/metricswhenhttp_portis set.istos/capabilities+export_capabilities()(includeschannel+ optionalwebsocket)- AsyncAPI /
serve_docs()includes@streamand@channel - Request envelope on attachments (
tok/cid/tp) require_auth=True(raisesIstosSecurityErrorwithout an authorizer)JWTAuthorizer,require_roles(istos[jwt])authorizer=on subscriberstoken=on@query,query_once,publish_once,stream_query,open_channelRateLimitMiddleware— token-bucket rate limits- Middleware wraps
@handle,@stream,@channel,@subscribe(stream/channel once per session) - Structured logging (
log_level,json_logs) IstosError/ErrorResponse/@exception_handlerIstosTestClient(query, stream, channel, publish).istos/health,.istos/ready,.istos/metrics- Prometheus metrics + optional OTel (
istos[otel]) - SIGINT/SIGTERM shutdown
InMemoryStoragePlugin,RedisStoragePlugin,SqlAlchemyStoragePlugin- Pydantic / type-hint validation at the boundary
- Retry with backoff
- Liveliness
- Dependency injection
- Shared memory transfers
- CLI:
istos new/docs/version/analyze - Architecture fitness (
istos analyze) — abstractness / instability / distance - CI, Docker Compose, deployment docs,
SECURITY.md,py.typed - Wire-protocol reference (including
@channelfabric keys)
Extras¶
istos[all]= redis + sqlalchemy + otel + s3 + jwt