bartoszkobylinski opened a new issue, #3735:
URL: https://github.com/apache/iggy/issues/3735
### 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:
1. **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)`.
2. **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`):
```rust
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.
3. **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`.
4. **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."*
5. **`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
```python
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
1. Start `iggy-server` 0.8.0 with the TCP transport on `127.0.0.1:8092`.
2. Run the script above with `apache-iggy` 0.8.0. It creates a stream and a
topic with
`partitions_count=3`.
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.
4. 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.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]