Protocol Support

Supported Kafka APIs and versions, negotiation, and how parity with Apache Kafka is enforced in CI.

Overview

krafka implements the Kafka wire protocol with support for:

  • Automatic API version negotiation
  • Multiple protocol versions per API
  • All standard compression codecs
  • Zero-copy message handling

Version Negotiation

On connection, krafka automatically fetches the broker's supported API versions and stores them. This enables dynamic version negotiation for optimal compatibility and feature usage.

How It Works

  1. Client connects to broker
  2. Client sends ApiVersions request
  3. Broker responds with supported API version ranges
  4. Client stores version ranges for future requests
  5. Each request negotiates the best version within the client's [MIN, MAX] range

Bootstrapping ApiVersions itself

ApiVersions is the one API whose version cannot be negotiated from a previous ApiVersions response — it is the negotiation. krafka therefore sends versions::API_VERSIONS_MAX (v4 by default) and, if the broker answers UNSUPPORTED_VERSION, retries at the ceiling that rejection advertises. The protocol mandates that a rejection is encoded with the v0 response layout precisely so a client that guessed too high can still parse the reply, so the fallback costs exactly one extra round trip and never fails the handshake.

The default ceiling is the highest version a released Kafka supports, not the highest krafka can encode: sending v5 (KIP-1242, unreleased) to a Kafka 4.x broker would cost a rejected round trip on every connection. v5 is available behind the unstable-protocol feature for testing against unreleased builds.

Negotiating v3+ rather than pinning v0 is what puts two things on the wire:

  • KIP-511ClientSoftwareName / ClientSoftwareVersion, which is how a broker's client.software.name / client.software.version metrics identify krafka. These fields do not exist below v3.
  • KIP-584SupportedFeatures and FinalizedFeatures, carried in v3+ tagged fields. krafka caches them per connection; read them with BrokerConnection::broker_features(), which exposes finalized_level(name) and supported_range(name) so callers can gate optional behaviour on a cluster-wide feature level without a second round trip.

Using Version Negotiation

use krafka::protocol::ApiKey;

// negotiate_api_version(api_key, max, min) clamps to client MIN..MAX and broker range.
let fetch_version = conn
    .negotiate_api_version(ApiKey::Fetch, 12, 4)
    .await
    .expect("broker does not support any usable Fetch version");
println!("Using Fetch v{}", fetch_version);

Leader epochs end to end (KIP-320)

KIP-320 only detects log truncation if the leader epoch travels with the position everywhere it goes. krafka sends it on all three legs:

LegFieldWhat it buys
Fetchcurrent_leader_epoch, last_fetched_epochThe broker reports diverging_epoch when the client's log no longer matches its own.
ListOffsetscurrent_leader_epochA reset resolved against a stale leader is rejected with FENCED_LEADER_EPOCH instead of returning an offset from a log the client cannot vouch for.
OffsetCommitOffsetFetchcommitted_leader_epochThe check survives a restart or a rebalance: the next owner of the partition resumes with the epoch the position was read at.

Missing any one leg silently degrades the guarantee rather than breaking visibly — the client keeps working and simply stops noticing truncation. The commit leg is the easiest to overlook, because its absence is invisible until a consumer restarts.

A fenced ListOffsets forces a metadata refresh before the retry, so the epoch check converges instead of failing identically forever.

-1 remains the correct value where the client genuinely has no epoch: a position that came from a seek() or an offset reset, or an offset set administratively through AdminClient::alter_consumer_group_offsets. Inventing one there would defeat the check it feeds.

Minimum Broker Version

krafka requires Apache Kafka 3.9 or later. The MIN constants for all APIs are set so that pre-3.9 protocol features (e.g., Metadata v0, Produce v0-v2, Fetch v0-v3) are no longer supported. Connecting to an older broker will fail version negotiation for most APIs.

Client Supported Versions

Every API has a MIN and MAX constant in krafka::protocol::versions. The client only encodes/decodes versions within [MIN, MAX]; versions outside this range are rejected with a protocol error.

APIMinMaxKey Features
Produce313v3 transactions, v9 flexible encoding, v11 ZStd compression, v13 topic UUIDs (KIP-516)
Fetch418v4 isolation level, v7 fetch sessions (KIP-227), v9 leader epoch (KIP-320), v11 closest-replica (KIP-392), v12 flexible, v13 topic UUIDs (KIP-516), v15 remove ReplicaId (KIP-903), v17 directory ID (KIP-853), v18 high-watermark (KIP-1166)
ListOffsets111v1 timestamp queries, v2 isolation level, v4 leader epoch, v6 flexible, v7 max_timestamp, v8 tiered-storage, v9 KIP-1005, v10 KIP-1075 timeout, v11 KIP-1023
Metadata113v1 controller + rack, v7 leader epoch, v8 authorized-ops, v9 flexible, v10 topic UUIDs, v12 topic-ID lookup, v13 top-level error_code
OffsetCommit210v2 retention, v5 drops retention_time, v6 leader epoch, v8 flexible, v9 KIP-848 member_epoch, v10 topic UUIDs (KIP-848)
OffsetFetch110v1 group coordinator, v2 top-level error, v6 flexible, v8 batched groups, v9 KIP-848 member_epoch, v10 topic UUIDs (KIP-848)
FindCoordinator16v1 key_type, v3 flexible, v4 batched keys (KIP-699), v6 share groups (KIP-932)
JoinGroup49v4 group_instance_id (KIP-345), v6 flexible, v8 reason (KIP-800)
Heartbeat34v3 group_instance_id (KIP-345), v4 flexible
SyncGroup35v3 group_instance_id, v4 flexible, v5 protocol_type/name (KIP-559)
LeaveGroup35v3 batch leave (KIP-345), v4 flexible, v5 reason (KIP-800)
CreateTopics27v2 topic validation, v5 flexible, v7 topic_id (KIP-464, KIP-525)
DeleteTopics16v1 baseline, v4 flexible, v6 topic-ID-based deletion
CreatePartitions03v0 baseline, v2 flexible, v3 KIP-599
DescribeConfigs14v1 synonyms, v3 config_type + documentation, v4 flexible (Kafka 4.0 removed v0)
IncrementalAlterConfigs01v0 non-flexible, v1 flexible encoding
DescribeAcls13v1 prefixed ACLs, v2 flexible, v3 user resource type
CreateAcls13v1 prefixed ACLs, v2 flexible, v3 user resource type
DeleteAcls13v1 prefixed ACLs, v2 flexible, v3 user resource type
DescribeGroups16v3 authorized_operations, v4 static members, v5 flexible, v6 KIP-1043
ListGroups15v3 flexible, v4 state filter (KIP-518), v5 type filter (KIP-848)
DeleteRecords02v0 baseline, v2 flexible encoding
OffsetForLeaderEpoch24v2 leader epoch validation, v3 replica_id, v4 flexible
InitProducerId05 (6¹)v0 idempotent, v2 flexible, v3 epoch recovery, v4 latest stable, v5 KIP-890 txn_state, v6 KIP-939 two-phase commit
AddPartitionsToTxn05v0 baseline, v3 flexible encoding, v4–v5 KIP-890 Transactions array format
AddOffsetsToTxn04v0 baseline, v3 flexible encoding, v4 KIP-890 error codes
EndTxn05v0 baseline, v3 flexible encoding, v4–v5 KIP-890 epoch bump + txn_state
TxnOffsetCommit05v0 baseline, v2 leader epoch, v3 flexible + consumer fields, v4–v5 KIP-890 fields
WriteTxnMarkers12Broker-facing transaction marker write; v2 flexible
DescribeProducers00Active producer state per partition (KIP-664), for diagnosing hung transactions
DescribeTransactions00Transaction state by transactional ID (KIP-664)
CreateDelegationToken13v2 flexible, v3 owner override
RenewDelegationToken12v2 flexible encoding
ExpireDelegationToken12v2 flexible encoding
DescribeDelegationToken13v2 flexible, v3 token requester
DescribeUserScramCredentials00SCRAM credential metadata — mechanism and iteration count only; Kafka never returns salt or stored key
AlterUserScramCredentials00Create, update or delete SCRAM credentials (KIP-554)
DescribeClientQuotas01v1 flexible encoding
AlterClientQuotas01v1 flexible encoding
DeleteGroups02Consumer group deletion
OffsetDelete00Delete committed offsets for specific partitions without deleting the group
DescribeCluster02Cluster metadata
DescribeLogDirs15v2 flexible, v3 top-level error_code, v4 TotalBytes + UsableBytes, v5 IsCordoned (KIP-1066)
AlterReplicaLogDirs12Move a replica between log directories on a broker; v2 flexible
AlterPartitionReassignments01v0 KIP-455, v1 AllowReplicationFactorChange
ListPartitionReassignments00v0 only (KIP-455)
DescribeQuorum02v0 KRaft quorum state, v1 replica timestamps (KIP-836), v2 Nodes + ReplicaDirectoryId + error messages (KIP-853)
ElectLeaders02v0 preferred-only, v1 ElectionType (KIP-460), v2 flexible
ListTransactions02v0 KIP-664, v1 DurationFilter (KIP-994), v2 TransactionalIdPattern (KIP-1152)
ListConfigResources01v0 client metrics (KIP-714), v1 arbitrary resource types (KIP-1142)
ApiVersions04 (5¹)API version negotiation
ConsumerGroupHeartbeat01KIP-848 consumer group protocol, v1 KIP-1082 regex
ConsumerGroupDescribe01KIP-848 group description
DescribeTopicPartitions00Topic partition metadata (KIP-966)
UpdateFeatures02Cluster feature versioning (KIP-584), v1 UpgradeType + ValidateOnly, v2 drops per-feature results
GetTelemetrySubscriptions²00KIP-714 client telemetry subscription discovery
PushTelemetry²00KIP-714 client telemetry push
ShareGroupHeartbeat¹11KIP-932 share group heartbeat
ShareGroupDescribe¹11KIP-932 share group description
ShareFetch¹12KIP-932 share fetch, v2 acquire mode (KIP-1206) + renew ack (KIP-1222)
ShareAcknowledge¹12KIP-932 share acknowledge, v2 renew ack (KIP-1222)
DescribeShareGroupOffsets01KIP-932 share-partition start offsets, v1 Lag (KIP-1226)
AlterShareGroupOffsets00KIP-932 share-group offset reset (group must be empty)
StreamsGroupDescribe00KIP-1071 Streams group describe — topology, members, task assignments and changelog offsets
DeleteShareGroupOffsets00KIP-932 share-group offset deletion (group must be empty)

¹ Requires the unstable-protocol feature flag. Where a max is shown in parentheses, that is the feature-gated ceiling.

² Requires the telemetry feature flag.

The share-group offset administration APIs (keys 90–92) are not behind unstable-protocol: they are ordinary AdminClient operations and are compiled unconditionally. Only the ShareConsumer itself is gated.

StreamsGroupDescribe (key 89) is likewise ungated — it is an ordinary AdminClient operation. Its sibling StreamsGroupHeartbeat (key 88) is deliberately not implemented: see below.

KIP-1071: why only the describe half

KIP-1071 adds two APIs, and krafka implements exactly one of them.

StreamsGroupHeartbeat (key 88) is a Streams runtime API. Its request carries the application topology — subtopologies, repartition topics, changelog topics — and the coordinator assigns tasks from it. Sending it requires being a Streams runtime. A client that fabricated a topology would not merely be wrong locally: the topology is group-wide state, so it would corrupt what every real member of that group is assigned from. krafka has no Streams layer, so it does not send this.

StreamsGroupDescribe (key 89) is purely observational and is what an operator actually needs — see Admin Client → Streams Groups.

How this table stays honest

Two mechanisms, neither of which is "someone remembered".

Within the crate, the rows above are generated from the same api_versions! macro tokens that initialise the *_MIN / *_MAX constants, so the published table cannot understate or overstate what the client negotiates.

Against Kafka, just protocol-parity diffs the table against Apache Kafka's own message schemas and fails CI on any of five conditions:

CheckCatches
Name and key agreeA protocol rename applied to one place only — e.g. ListClientMetricsResourcesListConfigResources in Kafka 4.1
MIN is still validA version Kafka removed in a major release, which every broker now rejects
MAX does not overstateAn ungated row naming a version Kafka marks latestVersionUnstable
MAX does not understateA stable Kafka version this client silently declines to negotiate
Flexible boundary matchesAn off-by-one in ApiKey::flexible_version(), which would make every request unparseable

The check reads a vendored snapshot (xtask/kafka_protocol_snapshot.json), so it needs no network and cannot flake. Track a newer Kafka release deliberately:

just refresh-protocol-snapshot 4.3   # rewrite the snapshot; review the diff
just protocol-parity                 # see what krafka must do about it

Deliberate omissions live in a DELIBERATE_GAPS table in the script, each with a written reason — broker-internal APIs, the KIP-1071 Streams protocol, the legacy AlterConfigs that IncrementalAlterConfigs supersedes.

Why some ceilings stop short

A schema marked latestVersionUnstable: true is not advertised by a released broker unless it was started with unstable.api.versions.enable=true, so implementing it buys nothing and costs a rejected round trip on every connection.

In Kafka 4.3 exactly one API carries that flag: InitProducerId v6 (KIP-939 two-phase commit), which is why it sits behind unstable-protocol. ListOffsets v11, AddPartitionsToTxn v5, EndTxn v5 and DescribeQuorum v2 set it explicitly to false — they are stable, and krafka negotiates all of them.

Version Constants

Client-supported versions are defined in krafka::protocol::versions:

use krafka::protocol::versions;

// Each API has both MIN and MAX constants
let min_fetch = versions::FETCH_MIN;        // 4  (Kafka 3.9+ baseline)
let max_fetch = versions::FETCH_MAX;        // 18 (v18 KIP-1166 high-watermark)
let min_produce = versions::PRODUCE_MIN;    // 3  (v3+ transactions)
let max_produce = versions::PRODUCE_MAX;    // 13 (v13 topic UUIDs, KIP-516)
let max_metadata = versions::METADATA_MAX;  // 13 (v13 top-level error_code)

Record Batches

krafka uses Kafka's v2 record batch format with:

  • Magic byte 2 (modern format)
  • CRC32C checksums (validated on decode)
  • Variable-length encoding for efficiency
  • Optional compression (gzip, snappy, lz4, zstd)

Header Versioning

Every Kafka request/response is prefixed with a header whose format depends on whether the API version uses flexible encoding:

Header stateRequest headerResponse header
Non-flexiblev1 — standard KafkaString for client_idv0 — correlation_id only
Flexiblev2 — compact string for client_id + tagged fieldsv1 — correlation_id + tagged fields

The transition version varies per API (e.g., Fetch becomes flexible at v12, Produce at v9). ApiKey::flexible_version() returns the threshold for each API, and the header is selected automatically by RequestHeader::encode() / ResponseHeader::decode().

Note: ApiVersions response always uses header v0 regardless of the API version (needed for protocol bootstrapping).

Unified Version Dispatch

Core request/response message types in krafka::protocol implement the VersionedEncode and VersionedDecode traits, which dispatch to the correct encode_vN/decode_vN method based on the protocol version number:

use krafka::protocol::{VersionedEncode, VersionedDecode, MetadataRequest, MetadataResponse};

let request = MetadataRequest::all_topics();
let mut buf = bytes::BytesMut::new();

// Encode for a specific protocol version — dispatches to the right encoder
request.encode_versioned(1, &mut buf)?;

// In real usage, `response_buf` would be filled with bytes read from the network.
let mut response_buf = buf.freeze();

// Decode response for a specific version
let response = MetadataResponse::decode_versioned(1, &mut response_buf)?;

Unsupported version numbers (including negative values) return a descriptive KrafkaError::protocol error.

Creating Records

use krafka::protocol::{RecordBatchBuilder, Compression};

let batch = RecordBatchBuilder::new()
    .compression(Compression::Snappy)
    .add_record(Some(b"key"), Some(b"value"), vec![])
    .add_record(None, Some(b"value-only"), vec![])
    .build()?;

Compression Support

CodecFeatureNotes
NoneDefaultNo compression
Gzipgzip via default compressionGood compression, slower
Snappysnappy via default compressionFast, moderate compression
LZ4lz4 via default compressionVery fast, good compression
Zstdzstd or compression-allBest compression, fast; requires a C toolchain via zstd-sys

Note: Decompression output is capped at 128 MiB by default to protect against compression bombs. This limit is configurable via ConsumerConfig::max_decompressed_size(). Compressed payloads that expand beyond the limit will return a KrafkaError::compression error.

Protocol Safety

krafka protects against malicious or corrupted broker responses:

  • Decode array bounds: Every array-length field decoded from the wire is validated against MAX_DECODE_ARRAY_LEN (100,000), typically via check_decode_array_len() and in some specialized decode paths (e.g., KafkaArray::decode, record batch counts) via equivalent local checks. These checks reject negative counts and oversized counts across all 63+ protocol-message decode sites, KafkaArray decode paths, and record batch/header counts. The validation runs before any Vec::with_capacity() allocation, preventing both OOM and runaway decode loops.
  • Decompression limits: Decompressed record data is limited to 128 MiB (configurable) via streaming .take() limits and post-decompression size checks
  • Record headers: Record headers are preserved during batch building — no silent data loss
  • Encode validation: The TryEncode trait provides fallible encoding for protocol primitives (KafkaString, KafkaBytes, KafkaArray<T> where T: TryEncode, TaggedFields), returning errors instead of panicking on oversized data. ProducerRecord::validate() checks wire-format limits at the API boundary before encoding
  • Fuzz testing: The fuzz/ directory provides cargo-fuzz targets for KafkaArray decode, RecordBatch decode, and response message decode across multiple API versions. See fuzz/README.md for usage.

Wire Protocol

Request/Response Framing

+----------------+----------------+
|  Size (4B)     |  Data (N bytes)|
+----------------+----------------+

All messages are length-prefixed with a 4-byte big-endian size field.

Request Header

+----------+----------+---------------+-------------+
| API Key  | Version  | Correlation ID| Client ID   |
| (2 bytes)| (2 bytes)| (4 bytes)     | (variable)  |
+----------+----------+---------------+-------------+

Response Header

+---------------+
| Correlation ID|
| (4 bytes)     |
+---------------+

Zero-Copy Design

krafka uses bytes::Bytes throughout for zero-copy buffer management:

  • Incoming data is parsed without copying
  • Record payloads share underlying buffers
  • Memory is released when last reference drops

Broker Compatibility

krafka negotiates every API version rather than pinning them, so it works against any broker that speaks the Kafka wire protocol at the negotiated range — Apache Kafka 3.9+, and Kafka-compatible systems whose advertised versions overlap krafka's floors.

Apache Kafka 3.9 → 4.3

Apache Kafka is the reference target: the version table above is diffed against Kafka's own message schemas in CI (just protocol-parity), and the Docker integration suite can be run against every supported minor in one command:

just integration-matrix                  # Kafka 3.9.0 → 4.3.0
just integration-matrix "4.2.0 4.3.0"    # a subset
KAFKA_VERSION=4.3.0 just integration     # a single version

Redpanda

Redpanda works out of the box — no configuration, no feature flag:

  • Version negotiation lands inside Redpanda's advertised ranges for every API krafka's clients need (krafka's floors — Produce v3, Fetch v4, Metadata v1 — sit well below what current Redpanda advertises).
  • Transactions fall back to TV1 automatically. Redpanda does not implement server-side KIP-890 transaction version 2; krafka's TransactionalProducer probes the cluster's finalized transaction.version feature at init_transactions() and uses the classic explicit-AddPartitionsToTxn protocol when the feature is absent — the same fallback the Java 4.x client performs.
  • KIP-848 consumer groups are implemented by current Redpanda, so the next-generation group protocol works there too; the classic protocol is the fallback either way.
  • What does not apply: APIs Redpanda does not implement — KIP-932 share groups (ShareConsumer), log-dir administration, DescribeQuorum — fail fast with a clear UnknownApiVersion error rather than degrading silently, exactly as they do against an older Apache Kafka.

A dedicated smoke suite pins this against a real Redpanda container, including the TV1 fallback and read_committed visibility:

just integration-redpanda
REDPANDA_VERSION=v25.1.1 just integration-redpanda   # pin a tag

Next Steps