Bug description
When a send_messages call is rejected by the server, the error that reaches the client
carries no information about the request. Every field of PartitionNotFound arrives zeroed,
and two genuinely different requests produce byte-identical errors:
PartitionNotFound(0, Identifier { kind: Numeric, length: 4, value: [0, 0, 0, 0] },
Identifier { kind: Numeric, length: 4, value: [0, 0, 0, 0] })
The variant is PartitionNotFound(partition_id, topic_id, stream_id) — per the format
string in core/common/src/error/iggy_error.rs: "Partition with ID: {0} for topic with ID:
{1} for stream with ID: {2} was not found." So the message asserts partition 0 of topic 0,
stream 0, and none of those three values was requested. The actual request named an
existing stream and topic (both had just resolved successfully via get_stream /
get_topic) and an out-of-range partition id.
Expected: the error names the partition that was rejected and the stream/topic the
request addressed — or, failing that, says nothing about them rather than reporting values
that were never sent.
This is not specific to PartitionNotFound. The mechanism below applies to every
field-carrying IggyError variant over the binary protocol — StreamIdNotFound,
TopicIdNotFound, InvalidOffset, ClientNotFound and the rest all arrive with zeroed or
empty payloads.
Root cause
The server builds the error correctly; the wire format discards the payload and the client
manufactures a replacement out of Default. Line references are given at tag
server-0.8.0, with the master path where it moved:
-
The server builds the error with the real ids.
core/server/src/shard/communication.rs:95 —
IggyError::PartitionNotFound(ns.partition_id(), topic_id, stream_id).
-
The server serializes the discriminant and an empty body.
core/common/src/sender/mod.rs:200-208 (on master:
core/server/src/sender/mod.rs:199-207):
pub(crate) async fn send_error_response<T>(stream: &mut T, error: IggyError) -> Result<(), IggyError> {
send_response(stream, &error.as_code().to_le_bytes(), &[]).await
}
error is consumed for its 4-byte code only. The payload is dropped here and never
reaches the wire.
-
The client returns before reading a body.
core/sdk/src/tcp/tcp_client.rs:214-236 returns Err as soon as status != 0. QUIC
behaves identically: core/sdk/src/quic/quic_client.rs:240-248.
-
The client rebuilds the variant from the bare code.
IggyError::from_code(status) → core/common/src/error/iggy_error.rs:521-523 →
IggyError::from_repr(code). strum's FromRepr documents: "For variants with
additional data, the returned variant will use the Default trait to fill the data."
-
Identifier::default() is exactly the observed payload.
core/common/src/types/identifier/mod.rs:60-68 →
{ kind: Numeric, length: 4, value: [0, 0, 0, 0] }.
The byte-identical string-vs-numeric errors in the log below are the empirical fingerprint
of step 4: two different requests cannot produce an identical error unless the payload was
never carried.
The HTTP transport is unaffected, which is the clearest evidence that the information
exists and is merely not transported: core/server/src/http/error.rs:86-87 serializes
reason: error.to_string() — the fully formatted message, real ids included — plus a
field hint ("partition_id" for this variant, line 91). So the same server-side error is
informative over HTTP and misleading over TCP/QUIC.
Suggested direction
Either would resolve it; both touch the wire protocol, so this is filed as an issue rather
than a PR, per CONTRIBUTING.md:
- Carry the payload. The error response body is already empty; put the formatted message
(or the encoded fields) in it, and have the client attach it when status != 0. This is
what the HTTP path already returns.
- If the wire format must not change, stop fabricating. Have the client-side
reconstruction return something carrying only what was actually received — e.g. a
ServerError { code: u32, name: &'static str } — instead of a fielded variant filled with
Default. from_code_as_string already produces the name, so the material is there.
An error saying "partition not found (3007)" is strictly better than one naming a partition,
topic and stream nobody asked about. The fabricated zeros are the harmful part; an admitted
unknown would have cost us nothing.
Affected area / component
Wire protocol / API · Iggy server · Rust SDK · Python SDK
Deployment
Pre-built binary taken from the apache/iggy:0.8.0 DockerHub image, run under systemd (not
in a container).
Versions
Server 0.8.0 (DockerHub apache/iggy:0.8.0) · Python SDK apache-iggy 0.8.0 (PyPI wheel,
cp312, manylinux). Source references above read at tag server-0.8.0 and at master
(6eb5805, 2026-07-22).
Reproduced on 0.8.0. Not run against master or an edge build — the statement that all
five steps are unchanged on master comes from reading the source at that commit, not from
executing it. Happy to re-run the script against apache/iggy:edge if that would help.
Hardware / environment
Small self-hosted x86_64 Linux VPS, single server instance, TCP transport on loopback
127.0.0.1:8092, Python 3.12.13.
Sample code
import asyncio
from apache_iggy import IggyClient, SendMessage
STREAM, TOPIC = "probe_stream", "p3"
async def main():
c = IggyClient("127.0.0.1:8092")
await c.connect()
await c.login_user("iggy", "<password>")
if await c.get_stream(STREAM) is None:
await c.create_stream(STREAM)
if await c.get_topic(STREAM, TOPIC) is None:
await c.create_topic(STREAM, TOPIC, 3) # 3 partitions
t = await c.get_topic(STREAM, TOPIC)
print("topic id:", t.id, "partitions:", t.partitions_count)
# Out of range: the topic has partitions 0..2
try:
await c.send_messages(STREAM, TOPIC, 3, [SendMessage(b"x")])
except Exception as e:
print("string ids ->", e)
# The same call with numeric identifiers
s = await c.get_stream(STREAM)
try:
await c.send_messages(s.id, t.id, 3, [SendMessage(b"x")])
except Exception as e:
print("numeric ids ->", e)
asyncio.run(main())
Logs
Client-side output. The two errors are byte-identical, and neither mentions partition 3:
topic id: 1 partitions: 3
string ids -> PartitionNotFound(0, Identifier { kind: Numeric, length: 4, value: [0, 0, 0, 0] }, Identifier { kind: Numeric, length: 4, value: [0, 0, 0, 0] })
numeric ids -> PartitionNotFound(0, Identifier { kind: Numeric, length: 4, value: [0, 0, 0, 0] }, Identifier { kind: Numeric, length: 4, value: [0, 0, 0, 0] })
The corresponding server-side line was not captured. It should contain the real ids — the
dispatch failure path formats the same error value
(core/server/src/tcp/connection_handler.rs:134-135), which is the information that never
reaches the client.
Iggy server config
Not relevant to this path and therefore not captured: send_error_response drops the
payload unconditionally, with no configuration branch. Happy to supply the resolved config
if a maintainer wants it ruled out.
Reproduction
- Start
iggy-server 0.8.0 with the TCP transport on 127.0.0.1:8092.
- Run the script above with
apache-iggy 0.8.0. It creates a stream and a topic with
partitions_count=3.
- It then sends to partition id
3 — one past the end — twice: once addressing the stream
and topic by string name, once by numeric id.
- Observe that both raise the same error, that all three fields are zero, and that neither
mentions the partition id that was actually rejected.
Why this matters in practice
The zeroed stream/topic identifiers point at the wrong cause. In our case
get_stream("...") and get_topic("...") had both just succeeded with string identifiers,
so an error claiming stream 0 / topic 0 not found read as "the SDK is mangling my
identifiers on this code path" — and sent the investigation after identifier handling for
most of a day. The actual cause was a partition id one higher than the topic had, which the
error never mentions.
Partition indexing itself is fine and correctly validated: a 1-partition topic accepts only
0, a 3-partition topic accepts 0,1,2 and rejects 3 and 4. The only problem is what
the rejection says.
Adjacent, and much cheaper to fix
The Python binding types this argument as a bare int named partitioning
(foreign/python/src/client.rs:258,275 — partitioning: u32 →
Partitioning::partition_id(partitioning)), and the generated stub documents nothing about
it. A reader cannot tell from the signature that it is a 0-based partition id rather than,
say, a PartitioningKind discriminator — we guessed the latter first, precisely because the
error payload gave nothing to correct the guess. A one-line docstring on the stub would have
prevented the whole detour, independently of the payload fix. Mentioning it here as context
rather than as a second request; happy to split it into its own issue if preferred.
Bug description
When a
send_messagescall is rejected by the server, the error that reaches the clientcarries no information about the request. Every field of
PartitionNotFoundarrives zeroed,and two genuinely different requests produce byte-identical errors:
The variant is
PartitionNotFound(partition_id, topic_id, stream_id)— per the formatstring in
core/common/src/error/iggy_error.rs: "Partition with ID: {0} for topic with ID:{1} for stream with ID: {2} was not found." So the message asserts partition 0 of topic 0,
stream 0, and none of those three values was requested. The actual request named an
existing stream and topic (both had just resolved successfully via
get_stream/get_topic) and an out-of-range partition id.Expected: the error names the partition that was rejected and the stream/topic the
request addressed — or, failing that, says nothing about them rather than reporting values
that were never sent.
This is not specific to
PartitionNotFound. The mechanism below applies to everyfield-carrying
IggyErrorvariant over the binary protocol —StreamIdNotFound,TopicIdNotFound,InvalidOffset,ClientNotFoundand the rest all arrive with zeroed orempty payloads.
Root cause
The server builds the error correctly; the wire format discards the payload and the client
manufactures a replacement out of
Default. Line references are given at tagserver-0.8.0, with themasterpath where it moved:The server builds the error with the real ids.
core/server/src/shard/communication.rs:95—IggyError::PartitionNotFound(ns.partition_id(), topic_id, stream_id).The server serializes the discriminant and an empty body.
core/common/src/sender/mod.rs:200-208(onmaster:core/server/src/sender/mod.rs:199-207):erroris consumed for its 4-byte code only. The payload is dropped here and neverreaches the wire.
The client returns before reading a body.
core/sdk/src/tcp/tcp_client.rs:214-236returnsErras soon asstatus != 0. QUICbehaves identically:
core/sdk/src/quic/quic_client.rs:240-248.The client rebuilds the variant from the bare code.
IggyError::from_code(status)→core/common/src/error/iggy_error.rs:521-523→IggyError::from_repr(code). strum'sFromReprdocuments: "For variants withadditional data, the returned variant will use the
Defaulttrait to fill the data."Identifier::default()is exactly the observed payload.core/common/src/types/identifier/mod.rs:60-68→{ kind: Numeric, length: 4, value: [0, 0, 0, 0] }.The byte-identical string-vs-numeric errors in the log below are the empirical fingerprint
of step 4: two different requests cannot produce an identical error unless the payload was
never carried.
The HTTP transport is unaffected, which is the clearest evidence that the information
exists and is merely not transported:
core/server/src/http/error.rs:86-87serializesreason: error.to_string()— the fully formatted message, real ids included — plus afieldhint ("partition_id"for this variant, line 91). So the same server-side error isinformative over HTTP and misleading over TCP/QUIC.
Suggested direction
Either would resolve it; both touch the wire protocol, so this is filed as an issue rather
than a PR, per
CONTRIBUTING.md:(or the encoded fields) in it, and have the client attach it when
status != 0. This iswhat the HTTP path already returns.
reconstruction return something carrying only what was actually received — e.g. a
ServerError { code: u32, name: &'static str }— instead of a fielded variant filled withDefault.from_code_as_stringalready produces the name, so the material is there.An error saying "partition not found (3007)" is strictly better than one naming a partition,
topic and stream nobody asked about. The fabricated zeros are the harmful part; an admitted
unknown would have cost us nothing.
Affected area / component
Wire protocol / API · Iggy server · Rust SDK · Python SDK
Deployment
Pre-built binary taken from the
apache/iggy:0.8.0DockerHub image, run under systemd (notin a container).
Versions
Server
0.8.0(DockerHubapache/iggy:0.8.0) · Python SDKapache-iggy0.8.0 (PyPI wheel,cp312, manylinux). Source references above read at tag
server-0.8.0and atmaster(
6eb5805, 2026-07-22).Reproduced on 0.8.0. Not run against
masteror an edge build — the statement that allfive steps are unchanged on
mastercomes from reading the source at that commit, not fromexecuting it. Happy to re-run the script against
apache/iggy:edgeif that would help.Hardware / environment
Small self-hosted x86_64 Linux VPS, single server instance, TCP transport on loopback
127.0.0.1:8092, Python 3.12.13.Sample code
Logs
Client-side output. The two errors are byte-identical, and neither mentions partition
3:The corresponding server-side line was not captured. It should contain the real ids — the
dispatch failure path formats the same error value
(
core/server/src/tcp/connection_handler.rs:134-135), which is the information that neverreaches the client.
Iggy server config
Not relevant to this path and therefore not captured:
send_error_responsedrops thepayload unconditionally, with no configuration branch. Happy to supply the resolved config
if a maintainer wants it ruled out.
Reproduction
iggy-server0.8.0 with the TCP transport on127.0.0.1:8092.apache-iggy0.8.0. It creates a stream and a topic withpartitions_count=3.3— one past the end — twice: once addressing the streamand topic by string name, once by numeric id.
mentions the partition id that was actually rejected.
Why this matters in practice
The zeroed stream/topic identifiers point at the wrong cause. In our case
get_stream("...")andget_topic("...")had both just succeeded with string identifiers,so an error claiming stream 0 / topic 0 not found read as "the SDK is mangling my
identifiers on this code path" — and sent the investigation after identifier handling for
most of a day. The actual cause was a partition id one higher than the topic had, which the
error never mentions.
Partition indexing itself is fine and correctly validated: a 1-partition topic accepts only
0, a 3-partition topic accepts0,1,2and rejects3and4. The only problem is whatthe rejection says.
Adjacent, and much cheaper to fix
The Python binding types this argument as a bare
intnamedpartitioning(
foreign/python/src/client.rs:258,275—partitioning: u32→Partitioning::partition_id(partitioning)), and the generated stub documents nothing aboutit. A reader cannot tell from the signature that it is a 0-based partition id rather than,
say, a
PartitioningKinddiscriminator — we guessed the latter first, precisely because theerror payload gave nothing to correct the guess. A one-line docstring on the stub would have
prevented the whole detour, independently of the payload fix. Mentioning it here as context
rather than as a second request; happy to split it into its own issue if preferred.