This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 4dec2a985 feat(gateways): Kafka Gateway add DeleteTopics API Key that 
deletes the Iggy topic only (#4400)
4dec2a985 is described below

commit 4dec2a985528843bed7ba0a5f2c13137a45ffd99
Author: ryerraguntla <[email protected]>
AuthorDate: Mon Oct 12 07:46:49 2026 -0400

    feat(gateways): Kafka Gateway add DeleteTopics API Key that deletes the 
Iggy topic only (#4400)
---
 .config/nextest.toml                               |   1 +
 gateways/kafka/README.md                           |  23 +-
 gateways/kafka/docs/BRIDGE_MAPPING.md              |  21 ++
 gateways/kafka/docs/MANUAL_TESTING.md              |  10 +-
 gateways/kafka/docs/SCOPE.md                       |  50 ++-
 gateways/kafka/scripts/ci-wire-fixtures.sh         |   2 +-
 gateways/kafka/src/bridge/iggy_bridge/topics.rs    |  25 ++
 gateways/kafka/src/protocol/api.rs                 |  12 +-
 gateways/kafka/src/protocol/bounds_guard.rs        |  73 ++++
 .../kafka/src/protocol/handlers/create_topics.rs   | 125 ++++---
 .../kafka/src/protocol/handlers/delete_topics.rs   | 404 +++++++++++++++++++++
 gateways/kafka/src/protocol/handlers/mod.rs        |  47 ++-
 gateways/kafka/tests/api_handler_tests.rs          |  58 ++-
 gateways/kafka/tests/common/scope.rs               |   1 +
 gateways/kafka/tests/common/wire.rs                |  47 ++-
 .../kafka/tests/delete_topics_real_bridge_tests.rs | 336 +++++++++++++++++
 gateways/kafka/tests/golden_wire_fixtures_tests.rs |  14 +-
 gateways/kafka/tests/kafka_client_e2e_tests.rs     |  71 ++++
 gateways/kafka/tests/sasl_tests.rs                 |   6 +-
 gateways/kafka/tests/version_firewall_tests.rs     |  34 +-
 20 files changed, 1235 insertions(+), 125 deletions(-)

diff --git a/.config/nextest.toml b/.config/nextest.toml
index 23119ec0d..bd0f105e5 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -64,6 +64,7 @@ filter = '''
     + binary_id(iggy-gateway-kafka::list_offsets_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::produce_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::create_topics_real_bridge_tests)
+    + binary_id(iggy-gateway-kafka::delete_topics_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::metadata_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::offsets_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::phase1_e2e_tests)
diff --git a/gateways/kafka/README.md b/gateways/kafka/README.md
index f78f6e155..6c729ce7d 100644
--- a/gateways/kafka/README.md
+++ b/gateways/kafka/README.md
@@ -1,18 +1,21 @@
 # Kafka gateway (`iggy-gateway-kafka`)
 
-Foundation layer for 
[apache/iggy#3421](https://github.com/apache/iggy/issues/3421): a TCP listener 
on the Kafka wire port that decodes requests and validates scoped API keys and 
versions. With a bridge, Produce, Fetch, ListOffsets, Metadata, CreateTopics, 
DescribeConfigs and AlterConfigs use Iggy. InitProducerId and consumer group 
coordination work with or without one.
+Foundation layer for 
[apache/iggy#3421](https://github.com/apache/iggy/issues/3421): a TCP listener 
on the Kafka wire port that decodes requests and validates scoped API keys and 
versions. With a bridge, Produce, Fetch, ListOffsets, Metadata, CreateTopics, 
DeleteTopics, DescribeConfigs and AlterConfigs use Iggy. InitProducerId and 
consumer group coordination work with or without one.
 
 > **Stub warning:** with `IGGY_KAFKA_BRIDGE_ENABLED=true`, Produce, Fetch, 
 > ListOffsets, Metadata,
-> CreateTopics, DescribeConfigs and AlterConfigs use Iggy. With the bridge off 
(the default), those
-> seven are stubs: Produce, Fetch and ListOffsets answer retriable 
`NOT_LEADER_OR_FOLLOWER` (6),
-> CreateTopics, DescribeConfigs and AlterConfigs answer `NOT_CONTROLLER` (41), 
and Metadata reports
-> every requested topic unknown. **CreateTopics and AlterConfigs run as the 
bridge's own
-> Iggy user**: with the bridge on and `IGGY_KAFKA_SASL_ENABLED` off (the 
default), any client that
-> can reach this port can create topics (up to 1000 partitions each) or 
shorten a topic's
+> CreateTopics, DeleteTopics, DescribeConfigs and AlterConfigs use Iggy. With 
the bridge off (the
+> default), those eight are stubs: Produce, Fetch and ListOffsets answer 
retriable
+> `NOT_LEADER_OR_FOLLOWER` (6), CreateTopics, DeleteTopics, DescribeConfigs 
and AlterConfigs answer
+> `NOT_CONTROLLER` (41), and Metadata reports every requested topic unknown. 
**CreateTopics,
+> DeleteTopics and AlterConfigs run as the bridge's own Iggy user**: with the 
bridge on and
+> `IGGY_KAFKA_SASL_ENABLED` off (the default), any client that can reach this 
port can create
+> topics (up to 1000 partitions each), delete topics and their data, or 
shorten a topic's
 > `retention.ms` low enough that the cleaner drops sealed segments, deleting 
 > data. With SASL on,
-> `AlterConfigs` additionally requires the authenticated principal to hold 
`manage_topics`; without
-> it every resource in the request fails `TOPIC_AUTHORIZATION_FAILED` (29) and 
nothing is written.
-> Every client reads and writes as the bridge's Iggy user. See 
[docs/SCOPE.md](docs/SCOPE.md).
+> `DeleteTopics` and `AlterConfigs` additionally require the authenticated 
principal to hold
+> `manage_topics`; without it every resource/name in the request fails
+> `TOPIC_AUTHORIZATION_FAILED` (29) and nothing is written or deleted. 
`CreateTopics` has no such
+> gate yet - SASL on does not protect it. Every client reads and writes as the 
bridge's Iggy user.
+> See [docs/SCOPE.md](docs/SCOPE.md).
 >
 > InitProducerId does real work too, with or without the bridge: it allocates 
 > a producer id, so a stock idempotent producer starts instead of failing at 
 > startup.
 >
diff --git a/gateways/kafka/docs/BRIDGE_MAPPING.md 
b/gateways/kafka/docs/BRIDGE_MAPPING.md
index 2d5d2ecd1..0e025399d 100644
--- a/gateways/kafka/docs/BRIDGE_MAPPING.md
+++ b/gateways/kafka/docs/BRIDGE_MAPPING.md
@@ -347,6 +347,27 @@ to group members the way Kafka does, in the client, and 
polls every partition by
 Iggy's group registry is used as an offset key and for nothing else, which
 [`OFFSET_STORAGE.md`](OFFSET_STORAGE.md) covers.
 
+## Topic deletion and the shared default stream
+
+`TopicMapping::resolve` sends every Kafka topic with no configured override to 
the same
+`default_stream` - one Iggy stream holding many Iggy topics, one per Kafka 
topic name. An
+override can point a specific Kafka topic at a different stream, but nothing 
stops two different
+overrides, or an override and the default, from naming the same stream for two 
different Kafka
+topics.
+
+`DeleteTopics` ([#3546](https://github.com/apache/iggy/issues/3546)) therefore 
deletes the Iggy
+*topic* only, never the Iggy *stream* it lives in - even when that delete 
leaves the stream with
+no topics left in it. A stream `DeleteTopics` happens to empty out is not the 
same thing as a
+stream nothing will ever use again: the next `CreateTopics` for an unrelated 
Kafka topic sharing
+that same default stream recreates exactly the state the delete just left, and 
this bridge has no
+way to tell "abandoned" apart from "temporarily empty" from here. Real Kafka 
has no analogous
+container object a topic delete would need to clean up either, so leaving the 
stream alone also
+matches what a Kafka client actually expects to observe.
+
+`ensure_stream_and_topic`'s own doc comment 
(`src/bridge/iggy_bridge/topics.rs`) flags the
+identical sharing risk from the create side: it never rolls back a stream it 
just created on a
+failed topic create, for the same reason.
+
 ## Reserved header namespace
 
 `kafka.` is reserved on messages the gateway writes. Nothing in the server 
enforces it, so the
diff --git a/gateways/kafka/docs/MANUAL_TESTING.md 
b/gateways/kafka/docs/MANUAL_TESTING.md
index c323c0d34..072eea17a 100644
--- a/gateways/kafka/docs/MANUAL_TESTING.md
+++ b/gateways/kafka/docs/MANUAL_TESTING.md
@@ -216,6 +216,7 @@ For each API key, test **min−1**, **min**, **max**, 
**max+1** using `kafka-mes
 | 1 | Fetch | 4 | 12 | 3, 4, 12, 13 |
 | 2 | ListOffsets | 1 | 6 | 0, 1, 6, 7 |
 | 19 | CreateTopics | 2 | 5 | 1, 2, 5, 6 |
+| 20 | DeleteTopics | 1 | 5 | 0, 1, 5, 6 |
 | 8 | OffsetCommit | 2 | 9 | 1, 2, 9, 10 |
 | 9 | OffsetFetch | 1 | 9 | 0, 1, 9, 10 |
 | 10 | FindCoordinator | 0 | 4 | −1, 0, 4, 5 |
@@ -231,8 +232,8 @@ For each API key, test **min−1**, **min**, **max**, 
**max+1** using `kafka-mes
 | ---- | ------ | ---------------------- | --------------------------- |
 | B1 | ApiVersions negotiation | `error_code=0`; body lists 14 API keys with 
correct min/max | KIP-511 exception: still answers, `error_code=35` 
(UNSUPPORTED_VERSION), v0 response header regardless of the request's own 
encoding |
 | B2 | Metadata out-of-range | N/A | **Connection closes**, no response sent - 
Metadata has no top-level error field to carry a version-correct error in |
-| B3 | 
Produce/Fetch/ListOffsets/CreateTopics/InitProducerId/OffsetCommit/OffsetFetch  
out-of-range | N/A | **Connection closes** for both above-max and below-min - 
`kafka_protocol`'s schema floor for each of these five messages equals 
`SUPPORTED_RANGES`' own min, so there is no encodable error response below min 
either (see `SCOPE.md`'s Governance model) |
-| B4 | ApiVersions lists only scoped keys | Decode response | Contains keys 
0,1,2,3,8,9,10,11,12,13,14,18,19,22,32,33 only, and no transaction keys (24, 
25, 26, 28) |
+| B3 | 
Produce/Fetch/ListOffsets/CreateTopics/DeleteTopics/InitProducerId/OffsetCommit/OffsetFetch
  out-of-range | N/A | **Connection closes** for both above-max and below-min - 
`kafka_protocol`'s schema floor for each of these six messages equals 
`SUPPORTED_RANGES`' own min, so there is no encodable error response below min 
either (see `SCOPE.md`'s Governance model) |
+| B4 | ApiVersions lists only scoped keys | Decode response | Contains keys 
0,1,2,3,8,9,10,11,12,13,14,18,19,20,22,32,33 only, and no transaction keys (24, 
25, 26, 28) |
 
 An out-of-range version only ever produces `error_code=35` on ApiVersions 
(B1); every other API
 key's out-of-range case closes the connection - see B2/B3. InitProducerId and 
Produce also send
@@ -256,7 +257,10 @@ decoded `ec`.
 | C1 | 15 | DescribeGroups | `send --host 127.0.0.1:9093 --api-key 15` | 
Connection closes, no response bytes |
 | C2 | 10 | FindCoordinator | `send --host 127.0.0.1:9093 --api-key 10` | 
Connection closes |
 | C3 | 17 | SaslHandshake | `send --host 127.0.0.1:9093 --api-key 17` | 
Connection closes |
-| C4 | 20 | DeleteTopics | `send --host 127.0.0.1:9093 --api-key 20` | 
Connection closes |
+| C4 | 20 | DeleteTopics | `send --host 127.0.0.1:9093 --api-key 20` | Sends 
v1-v6. Gateway answers v1-v5 (stub `NOT_CONTROLLER` (41) with the bridge off). 
v6 is above the advertised max and that one connection closes |
+
+Key 20 is scoped, so C4 is not an unsupported-key close. `send --api-key 20` 
walks v1-v6; the
+gateway answers v1-v5 and closes on v6, above the advertised max.
 
 Reconnect and send A2 in a **new** session after each of C1-C4 to confirm the 
*gateway* (not just
 that one connection) is still serving other clients.
diff --git a/gateways/kafka/docs/SCOPE.md b/gateways/kafka/docs/SCOPE.md
index 649f2ba71..0f1695466 100644
--- a/gateways/kafka/docs/SCOPE.md
+++ b/gateways/kafka/docs/SCOPE.md
@@ -2,14 +2,15 @@
 
 ## Issue #3421 — in scope (this iteration)
 
-A TCP listener on the Kafka wire port. It decodes requests, validates scoped 
API keys, versions and wire formats, and answers them. With a bridge, Produce, 
Fetch, ListOffsets, Metadata, CreateTopics, DescribeConfigs and AlterConfigs 
use Iggy.
+A TCP listener on the Kafka wire port. It decodes requests, validates scoped 
API keys, versions and wire formats, and answers them. With a bridge, Produce, 
Fetch, ListOffsets, Metadata, CreateTopics, DeleteTopics, DescribeConfigs and 
AlterConfigs use Iggy.
 
 **Stub semantics (important):** without a bridge, every API answers with a 
stub. Produce discards
 the payload and answers with retriable `NOT_LEADER_OR_FOLLOWER` (6). Fetch and 
ListOffsets answer 6
-too. CreateTopics, DescribeConfigs and AlterConfigs answer `NOT_CONTROLLER` 
(41), so clients do not believe
-topics were created or configs were read or changed. OffsetCommit and 
OffsetFetch answer retriable `COORDINATOR_LOAD_IN_PROGRESS`
-(14). Do not read `ec=0` from a stub as durable storage. Produce is the one 
API that stores records once you configure
-a bridge ([#3535](https://github.com/apache/iggy/issues/3535)).
+too. CreateTopics, DeleteTopics, DescribeConfigs and AlterConfigs answer 
`NOT_CONTROLLER` (41), so
+clients do not believe a topic was created, deleted, or that configs were read 
or changed.
+OffsetCommit and OffsetFetch answer retriable `COORDINATOR_LOAD_IN_PROGRESS`
+(14). Do not read `ec=0` from a stub as durable storage. Produce is the one 
API that stores records
+once you configure a bridge 
([#3535](https://github.com/apache/iggy/issues/3535)).
 
 | Deliverable | Status | Location |
 | ------------- | -------- | ---------- |
@@ -54,6 +55,7 @@ it knows the server supports flexible encoding.
 | 1 | Fetch | 4 | 12 | 4, 5, 6, 7, 8, 9, 10, 11, 12 | With a bridge: shared 
topic probes, paged `poll_messages` per partition with records, wait up to 
`max_wait_ms` (max 18 s). Without one: stub returns `NOT_LEADER_OR_FOLLOWER` 
(6) |
 | 2 | ListOffsets | 1 | 6 | 1, 2, 3, 4, 5, 6 | Decode request; stub response |
 | 19 | CreateTopics | 2 | 5 | 2, 3, 4, 5 | Decode request; stub returns 
`NOT_CONTROLLER` (41); `-1` partitions/RF = broker default on v4+ |
+| 20 | DeleteTopics | 1 | 5 | 1, 2, 3, 4, 5 | With a bridge: deletes the Iggy 
topic, never the backing stream (see `BRIDGE_MAPPING.md`). Without one: stub 
returns `NOT_CONTROLLER` (41). v6 (topic-id-based) not advertised - this bridge 
has no topic-id concept |
 | 8 | OffsetCommit | 2 | 9 | 2 … 9 | With a bridge: stores each offset as an 
Iggy external group offset, and a negative offset deletes it 
([`OFFSET_STORAGE.md`](OFFSET_STORAGE.md)). Without one: 
`COORDINATOR_LOAD_IN_PROGRESS` (14). Flexible encoding at v8+ |
 | 9 | OffsetFetch | 1 | 9 | 1 … 9 | With a bridge: reads each offset, -1 when 
none is stored, and a null topic list reads every topic that holds the group. 
Without one: 14. Flexible encoding at v6+ |
 | 10 | FindCoordinator | 0 | 4 | 0, 1, 2, 3, 4 | Answers "this gateway" for 
group keys; `TRANSACTIONAL_ID_AUTHORIZATION_FAILED` (53) for the transaction 
key type, `INVALID_REQUEST` (42) for share; flexible encoding at v3+ |
@@ -88,6 +90,7 @@ Use this table when configuring clients or generating wire 
fixtures with `kafka-
 | 14 | SyncGroup | 0–5 | v4 |
 | 18 | ApiVersions | 0–3 | v3 |
 | 19 | CreateTopics | 2–5 | v5 |
+| 20 | DeleteTopics | 1–5 | v4 |
 | 22 | InitProducerId | 0–5 | v2 |
 | 32 | DescribeConfigs | 1–4 | v4 |
 | 33 | AlterConfigs | 0–2 | v2 |
@@ -105,7 +108,7 @@ All API keys not listed above close the connection (see 
Governance model above)
 | 29 | DescribeAcls | Implemented behind `IGGY_KAFKA_SASL_ENABLED`, advertised 
only while it is on ([`ACL_MAPPING.md`](ACL_MAPPING.md)) |
 | 36 | SaslAuthenticate | Implemented behind `IGGY_KAFKA_SASL_ENABLED`, 
advertised only while it is on ([`AUTHENTICATION.md`](AUTHENTICATION.md)) |
 | 24, 25, 26, 28 | AddPartitionsToTxn, AddOffsetsToTxn, EndTxn, 
TxnOffsetCommit | Transactions - not supported, see below |
-| 20, 23, 27, 30, 31, 34, 35, 37+ | DeleteTopics, `OffsetForLeaderEpoch`, 
`WriteTxnMarkers`, `CreateAcls`/`DeleteAcls`, IncrementalAlterConfigs (44), 
etc. | Later issues. DescribeConfigs (32) and AlterConfigs (33) are supported |
+| 23, 27, 30, 31, 34, 35, 37+ | `OffsetForLeaderEpoch`, `WriteTxnMarkers`, 
`CreateAcls`/`DeleteAcls`, IncrementalAlterConfigs (44), etc. | Later issues |
 | 21 | DeleteRecords | Not advertised on purpose, see below |
 | 68 | ConsumerGroupHeartbeat | KIP-848 protocol, opt-in via 
`group.protocol=consumer`; the 4.0 default is still `classic` |
 
@@ -168,8 +171,8 @@ nor moved by retention. That is the same core change the 
ListOffsets `EARLIEST`
 | Layer | #3421 | Description |
 | ------- | ------- | ------------- |
 | **1 — Wire framing** | In scope | `server.rs` — custom, zero-copy frame I/O; 
`header.rs` delegates version selection to `kafka_protocol::messages::ApiKey` |
-| **2 — Request/response codecs** | Partial | Decode/encode via the 
`kafka_protocol` crate (broker feature only) for 14 keys; `bounds_guard.rs` 
pre-validates against unbounded allocation before handing a frame to the crate; 
stub responses except InitProducerId and the five consumer-group keys, and 
Produce, Fetch, ListOffsets, Metadata, CreateTopics, DescribeConfigs and 
AlterConfigs with a bridge |
-| **3 — Iggy bridge** | Produce, Fetch, ListOffsets, Metadata, CreateTopics, 
DescribeConfigs and AlterConfigs wired | `bridge/` module (connection, topic 
mapping, provisioning, high watermark, `topic_target` + `send_records`, `probe` 
+ `poll`). Produce ([#3535](https://github.com/apache/iggy/issues/3535)), Fetch 
([#3536](https://github.com/apache/iggy/issues/3536)), ListOffsets 
([#3537](https://github.com/apache/iggy/issues/3537)), Metadata 
([#3534](https://github.com/apache/iggy/issues/ [...]
+| **2 — Request/response codecs** | Partial | Decode/encode via the 
`kafka_protocol` crate (broker feature only) for 15 keys; `bounds_guard.rs` 
pre-validates against unbounded allocation before handing a frame to the crate; 
stub responses except InitProducerId and the five consumer-group keys, and 
Produce, Fetch, ListOffsets, Metadata, CreateTopics, DeleteTopics, 
DescribeConfigs and AlterConfigs with a bridge |
+| **3 — Iggy bridge** | Produce, Fetch, ListOffsets, Metadata, CreateTopics, 
DeleteTopics, DescribeConfigs and AlterConfigs wired | `bridge/` module 
(connection, topic mapping, provisioning, high watermark, `topic_target` + 
`send_records`, `probe` + `poll`). Produce 
([#3535](https://github.com/apache/iggy/issues/3535)), Fetch 
([#3536](https://github.com/apache/iggy/issues/3536)), ListOffsets 
([#3537](https://github.com/apache/iggy/issues/3537)), Metadata 
([#3534](https://github.com/apach [...]
 
 ---
 
@@ -238,6 +241,37 @@ below it are still open for the issues that build on top 
of it.
     request's own wire `timeout_ms` (clamped to `[1s, 30s]`) now bounds the 
whole handler's
     aggregate bridge work, not just decoded and discarded - a deadline that 
fires answers every
     topic `REQUEST_TIMED_OUT` rather than continuing to hold the shared 
lockstep `IggyClient`.
+- [x] Real DeleteTopics ([#3546](https://github.com/apache/iggy/issues/3546)): 
with
+      `IGGY_KAFKA_BRIDGE_ENABLED=true`, deletes the Iggy topic through
+      `IggyBridge::delete_kafka_topic`. 
`src/protocol/handlers/delete_topics.rs`,
+      `tests/delete_topics_real_bridge_tests.rs`. With the bridge off, the 
stub answers
+      `NOT_CONTROLLER` (41), matching CreateTopics' own stub convention.
+  - **Never deletes the backing Iggy stream**, even when the delete leaves it 
with no topics in
+    it. `TopicMapping::resolve` sends every unmapped Kafka topic to the same 
`default_stream`
+    (`ensure_stream_and_topic`'s own doc comment flags the identical sharing 
risk for create), so
+    a stream this call happens to empty may still be the live home for a 
different Kafka topic a
+    moment later - there is no way to tell the two cases apart from here, and 
real Kafka itself
+    has no analogous container object for a `DeleteTopics` to clean up. See
+    [`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md).
+  - Per-topic results are independent: one missing or invalid name in a batch 
does not block an
+    existing, validly-named topic elsewhere in the same request from being 
deleted.
+  - A name repeated within one request is rejected outright for every 
occurrence
+    (`INVALID_REQUEST`, 42, no bridge call for any occurrence), the same 
choice `CreateTopics`
+    makes for a repeated topic name: real Kafka's 
`ControllerApis.deleteTopics` answers
+    `INVALID_REQUEST` "Duplicate topic name" for every occurrence and deletes 
nothing, rather
+    than a first-occurrence-wins split that would let an `AdminClient` caller 
observe a delete it
+    never got a clean per-name answer for.
+  - Same bridge-fan-out bounds as CreateTopics: a request naming more than 100 
topic names is
+    rejected outright (`POLICY_VIOLATION`, no bridge call for any of them), 
and the wire
+    `timeout_ms` (clamped to `[1s, 30s]`) bounds the whole handler's aggregate 
bridge work. The
+    deadline is checked before each name's own bridge call starts, not only 
inside the
+    `timeout_at` wrapping it - `timeout_at` polls its inner future before it 
checks the delay, so
+    a deadline that already passed would otherwise still start a real delete.
+  - With SASL on, the authenticated principal must hold `manage_topics` - 
without it every name
+    in the request fails `TOPIC_AUTHORIZATION_FAILED` (29) and 
`delete_kafka_topic` is never
+    called. With SASL off there is no principal to check, and the request is 
not gated here.
+  - v6 (the `topics: Vec<DeleteTopicState>`, topic-id-based shape) is not 
advertised - this bridge
+    has no concept of a Kafka topic id, only the Kafka-side name 
`TopicMapping` resolves.
 - [x] Document partition mapping in [`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md):
   - Iggy partitions are **0-based** (same as Kafka) — direct `partition_id` 
mapping, no offset conversion
   - Kafka consumer groups do **not** map onto Iggy consumer groups. Assignment 
stays client-side, and Iggy's group registry is used as an offset key only 
([`OFFSET_STORAGE.md`](OFFSET_STORAGE.md))
diff --git a/gateways/kafka/scripts/ci-wire-fixtures.sh 
b/gateways/kafka/scripts/ci-wire-fixtures.sh
index b6ca97836..5251e8392 100755
--- a/gateways/kafka/scripts/ci-wire-fixtures.sh
+++ b/gateways/kafka/scripts/ci-wire-fixtures.sh
@@ -24,7 +24,7 @@ set -euo pipefail
 FIXTURES_DIR="gateways/kafka/tools/kafka-tool/kafka_messages"
 
 # API keys requested by api_handler_tests, version_firewall_tests, and 
server_e2e_tests.
-FIXTURE_API_KEYS=(0 1 2 8 9 10 11 12 13 14 19 22 32 33)
+FIXTURE_API_KEYS=(0 1 2 8 9 10 11 12 13 14 19 20 22 32 33)
 
 usage() {
   echo "Usage: $0 {generate|cleanup}" >&2
diff --git a/gateways/kafka/src/bridge/iggy_bridge/topics.rs 
b/gateways/kafka/src/bridge/iggy_bridge/topics.rs
index b7dda3335..b860af435 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/topics.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/topics.rs
@@ -260,6 +260,31 @@ impl IggyBridge {
         }
     }
 
+    /// Deletes the Iggy topic backing `kafka_topic`, resolved through the 
configured
+    /// [`TopicMapping`](crate::bridge::topic_map::TopicMapping).
+    ///
+    /// Deliberately leaves the backing Iggy stream alone, even when this 
delete empties it out:
+    /// `TopicMapping::resolve` sends every *unmapped* Kafka topic to the same 
`default_stream`
+    /// (`ensure_stream_and_topic`'s own doc comment flags the same sharing 
risk for create), so a
+    /// stream this call happens to empty may still be the live home for a 
different Kafka topic a
+    /// moment later - this call has no way to tell the two cases apart, and 
real Kafka itself has
+    /// no analogous container object for a `DeleteTopics` to clean up.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`BridgeError::InvalidKafkaTopicName`] if `kafka_topic` fails 
Kafka's own
+    /// topic-naming rules. Returns [`BridgeError::Timeout`] if a call takes 
longer than
+    /// `REQUEST_TIMEOUT`. Returns [`BridgeError::Iggy`] with 
`StreamIdNotFound` (1009) or
+    /// `TopicIdNotFound` (2010) if the mapped stream or topic doesn't exist, 
or for
+    /// connectivity/auth failures.
+    pub async fn delete_kafka_topic(&self, kafka_topic: &str) -> Result<(), 
BridgeError> {
+        validate_kafka_topic_name("kafka_topic", kafka_topic)?;
+        let (stream_name, topic_name) = 
self.config.topic_mapping.resolve(kafka_topic);
+        let stream_id = 
Identifier::named(stream_name).map_err(BridgeError::Iggy)?;
+        let topic_id = 
Identifier::named(topic_name).map_err(BridgeError::Iggy)?;
+        with_request_timeout(self.client.delete_topic(&stream_id, 
&topic_id)).await
+    }
+
     /// Looks up `kafka_topic`, resolved through the configured
     /// [`TopicMapping`](crate::bridge::topic_map::TopicMapping), without 
creating it.
     ///
diff --git a/gateways/kafka/src/protocol/api.rs 
b/gateways/kafka/src/protocol/api.rs
index b0f27feb5..c7fc01244 100644
--- a/gateways/kafka/src/protocol/api.rs
+++ b/gateways/kafka/src/protocol/api.rs
@@ -39,9 +39,9 @@ use crate::protocol::bounds_guard::{
 };
 use crate::protocol::handlers::init_producer_id::ProducerIdAllocator;
 use crate::protocol::handlers::{
-    alter_configs, api_versions, create_topics, decode_guarded, 
describe_configs, dispatch, fetch,
-    find_coordinator, heartbeat, init_producer_id, join_group, leave_group, 
list_offsets, metadata,
-    offset_commit, offset_fetch, produce, respond_or_close, sync_group,
+    alter_configs, api_versions, create_topics, decode_guarded, delete_topics, 
describe_configs,
+    dispatch, fetch, find_coordinator, heartbeat, init_producer_id, 
join_group, leave_group,
+    list_offsets, metadata, offset_commit, offset_fetch, produce, 
respond_or_close, sync_group,
 };
 use crate::protocol::probe_board::ProbeBoard;
 use crate::protocol::sasl::{
@@ -62,10 +62,11 @@ pub const API_KEY_SYNC_GROUP: i16 = 14;
 pub const API_KEY_SASL_HANDSHAKE: i16 = 17;
 pub const API_KEY_API_VERSIONS: i16 = 18;
 pub const API_KEY_CREATE_TOPICS: i16 = 19;
-pub const API_KEY_DESCRIBE_CONFIGS: i16 = 32;
-pub const API_KEY_ALTER_CONFIGS: i16 = 33;
+pub const API_KEY_DELETE_TOPICS: i16 = 20;
 pub const API_KEY_INIT_PRODUCER_ID: i16 = 22;
 pub const API_KEY_DESCRIBE_ACLS: i16 = 29;
+pub const API_KEY_DESCRIBE_CONFIGS: i16 = 32;
+pub const API_KEY_ALTER_CONFIGS: i16 = 33;
 pub const API_KEY_SASL_AUTHENTICATE: i16 = 36;
 
 pub const DEFAULT_KAFKA_PORT: u16 = 9093;
@@ -285,6 +286,7 @@ static SUPPORTED_RANGES: &[ApiVersionRange] = &[
     offset_fetch::RANGE,
     api_versions::RANGE,
     create_topics::RANGE,
+    delete_topics::RANGE,
     describe_configs::RANGE,
     alter_configs::RANGE,
     init_producer_id::RANGE,
diff --git a/gateways/kafka/src/protocol/bounds_guard.rs 
b/gateways/kafka/src/protocol/bounds_guard.rs
index 3d9f237e8..90c711fbf 100644
--- a/gateways/kafka/src/protocol/bounds_guard.rs
+++ b/gateways/kafka/src/protocol/bounds_guard.rs
@@ -703,6 +703,44 @@ pub fn validate_create_topics_shape(
     Ok(())
 }
 
+/// Mirrors the field order `DeleteTopicsRequest::decode` walks, for the 
`topic_names`-based
+/// shape (v1-v5) only.
+///
+/// This bridge's `RANGE` never advertises v6, the `topics: 
Vec<DeleteTopicState>`
+/// (topic-id-based) shape, so that branch is never reached and isn't walked 
here.
+///
+/// # Errors
+///
+/// Returns an error when a declared array/string/bytes length cannot fit in 
the bytes remaining
+/// in the frame, or the body is truncated or malformed in a way that cannot 
be walked.
+pub fn validate_delete_topics_shape(
+    version: i16,
+    body: &Bytes,
+    max_frame_size: usize,
+) -> Result<()> {
+    let mut c = ShapeCursor::new(body.clone(), max_frame_size);
+    let flexible = version >= 4;
+
+    let topic_names_count = if flexible {
+        c.compact_array_count()?
+    } else {
+        c.legacy_array_count()?
+    };
+    for _ in 0..topic_names_count {
+        if flexible {
+            c.compact_string(false)?;
+        } else {
+            c.legacy_string(false)?;
+        }
+    }
+
+    let _timeout_ms = c.read_i32()?;
+    if flexible {
+        c.tagged_fields()?;
+    }
+    Ok(())
+}
+
 /// Mirrors the field order `MetadataRequest::decode` walks.
 ///
 /// # Errors
@@ -1580,6 +1618,41 @@ mod tests {
         assert!(validate_create_topics_shape(5, &body, 
TEST_MAX_FRAME_SIZE).is_err());
     }
 
+    /// Same POC class as `create_topics_v5_huge_topics_count_rejected`, 
against
+    /// `validate_delete_topics_shape`'s own `topic_names` compact array count.
+    #[test]
+    fn delete_topics_v5_huge_topic_names_count_rejected() {
+        let body = Bytes::from_static(&[0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
+        assert!(validate_delete_topics_shape(5, &body, 
TEST_MAX_FRAME_SIZE).is_err());
+    }
+
+    /// Same desync class as `create_topics_v5_desync_varint_poc_rejected`: a 
5-byte over-long
+    /// varint consumed as the (empty) `topic_names` count, matching 
`kafka_protocol`'s own 5-byte
+    /// cap. `DeleteTopics` has one fewer field than `CreateTopics` between 
the array and the
+    /// request's tagged fields (`timeout_ms` only, no `validate_only`), so 
the padding here is
+    /// four bytes, not five, to land the huge trailing varint on the 
tagged-fields count instead.
+    #[test]
+    fn delete_topics_v5_desync_varint_poc_rejected() {
+        let body = Bytes::from_static(&[
+            0x81, 0x80, 0x80, 0x80, 0x80, // 5-byte over-long varint, decodes 
to count 0
+            0x00, 0x00, 0x00, 0x00, // timeout_ms
+            0xFF, 0xFF, 0xFF, 0xFF, 0x0F, // u32::MAX read as the 
tagged-fields count
+        ]);
+        assert!(validate_delete_topics_shape(5, &body, 
TEST_MAX_FRAME_SIZE).is_err());
+    }
+
+    /// Legacy (non-flexible) shape: a declared topic-name length with nothing 
behind it. Covers
+    /// the `legacy_string` branch `validate_delete_topics_shape` walks below 
v4, which the two
+    /// flexible-only POCs above never reach.
+    #[test]
+    fn delete_topics_v1_truncated_legacy_topic_name_rejected() {
+        let body = Bytes::from_static(&[
+            0x00, 0x00, 0x00, 0x01, // topic_names: legacy array count = 1
+            0x03, 0xE8, // first name's declared length: 1000, no bytes follow
+        ]);
+        assert!(validate_delete_topics_shape(1, &body, 
TEST_MAX_FRAME_SIZE).is_err());
+    }
+
     /// Reviewed POC (C2): 1 topic x 65,535 partitions was within the *old* 
65,536 cumulative
     /// budget - no single array count exceeded `MAX_COLLECTION_LEN`, only 
their product did -
     /// and drove a 393 KB request to a 2.16 MB response built by one 
synchronous,
diff --git a/gateways/kafka/src/protocol/handlers/create_topics.rs 
b/gateways/kafka/src/protocol/handlers/create_topics.rs
index 760d39bc6..d43c09d10 100644
--- a/gateways/kafka/src/protocol/handlers/create_topics.rs
+++ b/gateways/kafka/src/protocol/handlers/create_topics.rs
@@ -18,7 +18,6 @@
 //! `CreateTopics` (API key 19).
 
 use std::collections::HashSet;
-use std::time::Duration;
 
 use bytes::Bytes;
 use iggy::prelude::IggyError;
@@ -42,8 +41,8 @@ use crate::protocol::handlers::topic_config::{
     MAX_CONFIG_TOPICS, find_duplicate_names, topic_cap_message,
 };
 use crate::protocol::handlers::{
-    decode_guarded, encode_message, handle_versioned_request, 
is_supported_version,
-    respond_or_close, unsupported_version_response,
+    clamp_request_timeout, decode_guarded, encode_message, 
handle_versioned_request,
+    is_supported_version, respond_or_close, unsupported_version_response,
 };
 
 pub const RANGE: ApiVersionRange = ApiVersionRange {
@@ -66,21 +65,6 @@ const DEFAULT_PARTITION_COUNT: u32 = 1;
 /// `NONE` for a partition count the real path can never actually create.
 const MAX_PARTITIONS_COUNT: u32 = 1000;
 
-/// Bounds imposed on the request's own `timeout_ms` before it becomes the 
aggregate bridge-work
-/// deadline. That value is client-supplied and otherwise unchecked: `0` or 
negative would abort
-/// every topic on arrival, and an oversized one would tie up the shared 
`IggyClient` past any
-/// reasonable request.
-const MIN_REQUEST_TIMEOUT: Duration = Duration::from_millis(1_000);
-const MAX_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
-
-/// Clamps the wire's own `timeout_ms` (KIP-4's field for exactly this) into
-/// `[MIN_REQUEST_TIMEOUT, MAX_REQUEST_TIMEOUT]` - unlike 
`ListOffsets`/`Metadata`, `CreateTopics`
-/// carries a real client-supplied deadline to honor, not just a fixed 
internal ceiling.
-fn clamp_request_timeout(timeout_ms: i32) -> Duration {
-    let requested = 
Duration::from_millis(u64::try_from(timeout_ms).unwrap_or(0));
-    requested.clamp(MIN_REQUEST_TIMEOUT, MAX_REQUEST_TIMEOUT)
-}
-
 pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> 
HandleOutcome {
     let Some(bridge) = &state.bridge else {
         return handle_versioned_request(
@@ -173,9 +157,13 @@ pub async fn handle(state: &GatewayState, api_version: 
i16, body: Bytes) -> Hand
 /// `deadline` bounds each topic's own bridge work individually 
(`timeout_at`), not the whole
 /// loop: a single `timeout` around the entire call would discard every 
already-resolved result
 /// the moment one topic's call ran long, answering `REQUEST_TIMED_OUT` even 
for topics that had
-/// already committed. Once `deadline` passes, every remaining topic's own 
`timeout_at` elapses
-/// immediately rather than making a fresh bridge call, so a stuck topic near 
the front of a large
-/// batch does not turn into one slow round trip per topic behind it.
+/// already committed.
+///
+/// `tokio::time::timeout_at` polls the inner future before it ever checks the 
deadline (see
+/// `Timeout::poll`), so once `deadline` has already passed, wrapping a fresh 
bridge call in it
+/// still starts that call - a topic past the deadline would both answer 
`REQUEST_TIMED_OUT` *and*
+/// actually get created. The `Instant::now() >= deadline` check below must 
run and must skip the
+/// call itself, the same pattern `list_offsets.rs` uses, not rely on 
`timeout_at` to skip it.
 async fn create_all_topics(
     bridge: &IggyBridge,
     api_version: i16,
@@ -185,31 +173,52 @@ async fn create_all_topics(
     deadline: Instant,
 ) -> Vec<CreatableTopicResult> {
     let mut results = Vec::with_capacity(topics.len());
+    let mut deadline_exceeded = false;
     for topic in topics {
-        let result = if duplicate_names.contains(topic.name.as_str()) {
-            CreatableTopicResult::default()
-                .with_name(topic.name.clone())
-                .with_error_code(ERROR_INVALID_REQUEST)
-                .with_error_message(None)
-        } else {
-            match tokio::time::timeout_at(
-                deadline,
-                create_one_topic(bridge, api_version, topic, validate_only),
-            )
-            .await
-            {
-                Ok(result) => result,
-                Err(_elapsed) => {
-                    tracing::warn!(
-                        kafka_topic = topic.name.as_str(),
-                        "CreateTopics: this topic's bridge work exceeded the 
request deadline; \
-                         answering retriable instead of blocking further"
-                    );
-                    CreatableTopicResult::default()
-                        .with_name(topic.name.clone())
-                        .with_error_code(ERROR_REQUEST_TIMED_OUT)
-                        .with_error_message(None)
-                }
+        if duplicate_names.contains(topic.name.as_str()) {
+            results.push(
+                CreatableTopicResult::default()
+                    .with_name(topic.name.clone())
+                    .with_error_code(ERROR_INVALID_REQUEST)
+                    .with_error_message(None),
+            );
+            continue;
+        }
+        if deadline_exceeded || Instant::now() >= deadline {
+            if !deadline_exceeded {
+                deadline_exceeded = true;
+                tracing::warn!(
+                    "CreateTopics deadline passed; answering remaining topics 
retriable \
+                     instead of starting new Iggy calls"
+                );
+            }
+            results.push(
+                CreatableTopicResult::default()
+                    .with_name(topic.name.clone())
+                    .with_error_code(ERROR_REQUEST_TIMED_OUT)
+                    .with_error_message(None),
+            );
+            continue;
+        }
+
+        let result = match tokio::time::timeout_at(
+            deadline,
+            create_one_topic(bridge, api_version, topic, validate_only),
+        )
+        .await
+        {
+            Ok(result) => result,
+            Err(_elapsed) => {
+                deadline_exceeded = true;
+                tracing::warn!(
+                    kafka_topic = topic.name.as_str(),
+                    "CreateTopics: this topic's bridge work exceeded the 
request deadline; \
+                     answering retriable instead of blocking further"
+                );
+                CreatableTopicResult::default()
+                    .with_name(topic.name.clone())
+                    .with_error_code(ERROR_REQUEST_TIMED_OUT)
+                    .with_error_message(None)
             }
         };
         results.push(result);
@@ -750,19 +759,23 @@ mod tests {
     }
 
     #[test]
-    fn 
clamp_request_timeout_rejects_a_zero_or_negative_value_up_to_the_floor() {
-        assert_eq!(clamp_request_timeout(0), MIN_REQUEST_TIMEOUT);
-        assert_eq!(clamp_request_timeout(-1), MIN_REQUEST_TIMEOUT);
-        assert_eq!(clamp_request_timeout(i32::MIN), MIN_REQUEST_TIMEOUT);
-    }
-
-    #[test]
-    fn clamp_request_timeout_caps_an_oversized_value_at_the_ceiling() {
-        assert_eq!(clamp_request_timeout(i32::MAX), MAX_REQUEST_TIMEOUT);
+    fn 
find_duplicate_names_finds_a_name_repeated_across_two_requested_topics() {
+        let topics = [creatable_topic(1, 1), creatable_topic(1, 1)];
+        let names: Vec<&str> = topics.iter().map(|topic| 
topic.name.as_str()).collect();
+        let duplicates = find_duplicate_names(names);
+        assert_eq!(duplicates, HashSet::from(["orders"]));
     }
 
     #[test]
-    fn clamp_request_timeout_passes_through_a_reasonable_value_unchanged() {
-        assert_eq!(clamp_request_timeout(5_000), Duration::from_secs(5));
+    fn find_duplicate_names_is_empty_when_every_name_is_unique() {
+        let topics = [
+            creatable_topic(1, 1),
+            CreatableTopic::default()
+                .with_name(topic_name("payments"))
+                .with_num_partitions(1)
+                .with_replication_factor(1),
+        ];
+        let names: Vec<&str> = topics.iter().map(|topic| 
topic.name.as_str()).collect();
+        assert!(find_duplicate_names(names).is_empty());
     }
 }
diff --git a/gateways/kafka/src/protocol/handlers/delete_topics.rs 
b/gateways/kafka/src/protocol/handlers/delete_topics.rs
new file mode 100644
index 000000000..bd41fd5ae
--- /dev/null
+++ b/gateways/kafka/src/protocol/handlers/delete_topics.rs
@@ -0,0 +1,404 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! `DeleteTopics` (API key 20).
+//!
+//! Deletes the Iggy topic a Kafka topic name resolves to. Never deletes the 
backing Iggy
+//! stream - see [`IggyBridge::delete_kafka_topic`]'s own doc comment for why: 
the shared
+//! `default_stream` can hold other Kafka topics' data, and this bridge has no 
way to tell
+//! whether the stream it just emptied is actually abandoned or just 
temporarily topic-less.
+//!
+//! `DeletableTopicResult.error_message` is only encoded on the wire at v5+ 
(`kafka_protocol`'s
+//! own gate) - a client negotiating v1-v4 gets `error_code` only, with any 
text this module sets
+//! silently dropped. Unlike `CreateTopics`, whose equivalent field is encoded 
at every version it
+//! supports.
+
+use std::collections::HashSet;
+
+use bytes::Bytes;
+use iggy::prelude::IggyError;
+use kafka_protocol::messages::delete_topics_response::DeletableTopicResult;
+use kafka_protocol::messages::{DeleteTopicsRequest, DeleteTopicsResponse, 
TopicName};
+use kafka_protocol::protocol::StrBytes;
+
+use tokio::time::Instant;
+
+use crate::auth::AuthenticatedPrincipal;
+use crate::bridge::{BridgeError, IggyBridge};
+use crate::error::Result;
+use crate::protocol::api::{
+    API_KEY_DELETE_TOPICS, ApiVersionRange, ERROR_INVALID_REQUEST, ERROR_NONE,
+    ERROR_NOT_CONTROLLER, ERROR_POLICY_VIOLATION, ERROR_REQUEST_TIMED_OUT,
+    ERROR_TOPIC_AUTHORIZATION_FAILED, GatewayState, HandleOutcome,
+};
+use crate::protocol::bounds_guard::validate_delete_topics_shape;
+use crate::protocol::handlers::{
+    clamp_request_timeout, decode_guarded, encode_message, 
handle_versioned_request,
+    is_supported_version, respond_or_close, unsupported_version_response,
+};
+
+/// This bridge never advertises v6 (the `topics: Vec<DeleteTopicState>`, 
topic-id-based shape).
+///
+/// It has no concept of a Kafka topic id, only the Kafka-side name 
`TopicMapping` resolves. v1
+/// is the crate's own floor (`kafka_protocol` does not implement v0 for this 
message).
+pub const RANGE: ApiVersionRange = ApiVersionRange {
+    api_key: API_KEY_DELETE_TOPICS,
+    min_version: 1,
+    max_version: 5,
+};
+
+/// Cap on topic names one `DeleteTopics` request may address through the 
bridge.
+///
+/// Same rationale as `create_topics`'s own `MAX_CONFIG_TOPICS`-based cap, but 
counted differently: every
+/// name here costs its own bridge round trip against the single lockstep 
`IggyClient` every
+/// Kafka connection on this gateway shares, duplicates included, so this 
charges the raw count
+/// rather than the distinct one `CreateTopics` charges.
+const MAX_BRIDGE_BACKED_TOPICS: usize = 100;
+
+pub async fn handle(
+    state: &GatewayState,
+    principal: Option<&AuthenticatedPrincipal>,
+    api_version: i16,
+    body: Bytes,
+) -> HandleOutcome {
+    let Some(bridge) = &state.bridge else {
+        return handle_versioned_request(
+            API_KEY_DELETE_TOPICS,
+            api_version,
+            body,
+            |v, b| {
+                decode_guarded::<DeleteTopicsRequest>(v, b, |v, b| {
+                    validate_delete_topics_shape(v, b, state.max_frame_size)
+                })
+            },
+            encode_response,
+            encode_error_response,
+            "DeleteTopics",
+        );
+    };
+
+    if !is_supported_version(API_KEY_DELETE_TOPICS, api_version) {
+        return unsupported_version_response(API_KEY_DELETE_TOPICS, 
api_version, |version| {
+            encode_error_response(version, ERROR_INVALID_REQUEST)
+        });
+    }
+
+    let req = match decode_guarded::<DeleteTopicsRequest>(api_version, body, 
|v, b| {
+        validate_delete_topics_shape(v, b, state.max_frame_size)
+    }) {
+        Ok(req) => req,
+        Err(error) => {
+            // debug!, not warn!: attacker-controlled, not operator-actionable.
+            tracing::debug!(%error, "Failed to decode DeleteTopics request");
+            return respond_or_close(
+                encode_error_response(api_version, ERROR_INVALID_REQUEST),
+                "DeleteTopics",
+            );
+        }
+    };
+
+    if let Some(results) = authorize(principal, &req.topic_names) {
+        let resp = DeleteTopicsResponse::default().with_responses(results);
+        return respond_or_close(encode_message(&resp, api_version, 256), 
"DeleteTopics");
+    }
+
+    // RANGE caps at v5, so req.topics (the v6 topic-id shape) is always empty 
- topic_names is
+    // the only populated field at any version this bridge advertises.
+    if req.topic_names.len() > MAX_BRIDGE_BACKED_TOPICS {
+        tracing::warn!(
+            requested_topics = req.topic_names.len(),
+            max = MAX_BRIDGE_BACKED_TOPICS,
+            "DeleteTopics request addresses too many topics; rejecting"
+        );
+        let message = StrBytes::from(format!(
+            "this gateway addresses at most {MAX_BRIDGE_BACKED_TOPICS} topic 
names per DeleteTopics request"
+        ));
+        let results = req
+            .topic_names
+            .iter()
+            .map(|name| {
+                DeletableTopicResult::default()
+                    .with_name(Some(name.clone()))
+                    .with_error_code(ERROR_POLICY_VIOLATION)
+                    .with_error_message(Some(message.clone()))
+            })
+            .collect();
+        let resp = DeleteTopicsResponse::default().with_responses(results);
+        return respond_or_close(encode_message(&resp, api_version, 256), 
"DeleteTopics");
+    }
+
+    // Real Kafka (`ControllerApis.deleteTopics`) refuses every occurrence of 
a duplicate name
+    // with `INVALID_REQUEST` (42) and deletes nothing for it, the same choice 
this gateway's own
+    // `CreateTopics` already makes for a repeated topic name.
+    let duplicate_names = find_duplicate_names(&req.topic_names);
+
+    let deadline = Instant::now() + clamp_request_timeout(req.timeout_ms);
+    let results = delete_all_topics(bridge, &req.topic_names, 
&duplicate_names, deadline).await;
+    let resp = DeleteTopicsResponse::default().with_responses(results);
+    respond_or_close(encode_message(&resp, api_version, 256), "DeleteTopics")
+}
+
+/// Denies every requested topic when `principal` is known but is not allowed 
to delete topic
+/// data, or when its permissions could not be read. `None` means SASL is off, 
which this
+/// gateway treats as no principal to enforce against - the same choice the 
rest of this
+/// gateway makes when authentication itself is not configured.
+fn authorize(
+    principal: Option<&AuthenticatedPrincipal>,
+    topic_names: &[TopicName],
+) -> Option<Vec<DeletableTopicResult>> {
+    let principal = principal?;
+    if principal.permissions_known && principal.permissions.manage_topics {
+        return None;
+    }
+    if !principal.permissions_known {
+        // The permission read failed after a successful login, so this 
connection holds no real
+        // answer. Denying is the fail-closed choice: granting would authorize 
an irreversible
+        // delete off a value nothing ever actually read.
+        tracing::warn!(
+            principal = %principal.username,
+            "DeleteTopics asked on a connection whose permissions were never 
read; denying"
+        );
+    }
+    let message =
+        StrBytes::from_static_str("the authenticated principal is not 
authorized to delete topics");
+    Some(
+        topic_names
+            .iter()
+            .map(|name| {
+                DeletableTopicResult::default()
+                    .with_name(Some(name.clone()))
+                    .with_error_code(ERROR_TOPIC_AUTHORIZATION_FAILED)
+                    .with_error_message(Some(message.clone()))
+            })
+            .collect(),
+    )
+}
+
+/// Every topic name that appears more than once in `topic_names` - mirrors
+/// `create_topics::find_duplicate_names`.
+fn find_duplicate_names(topic_names: &[TopicName]) -> HashSet<TopicName> {
+    let mut seen = HashSet::with_capacity(topic_names.len());
+    let mut duplicates = HashSet::new();
+    for name in topic_names {
+        if !seen.insert(name.clone()) {
+            duplicates.insert(name.clone());
+        }
+    }
+    duplicates
+}
+
+/// Deletes every requested topic, independently of the others.
+///
+/// `deadline` bounds each topic's own bridge work individually 
(`timeout_at`), not the whole
+/// loop - same reasoning as `create_topics::create_all_topics`: a single 
`timeout` around the
+/// entire call would discard every already-resolved result the moment one 
topic's call ran
+/// long, answering a retriable code even for topics that had already deleted 
cleanly.
+///
+/// `tokio::time::timeout_at` polls the inner future before it ever checks the 
deadline (see
+/// `Timeout::poll`), so once `deadline` has already passed, wrapping a fresh 
bridge call in it
+/// still starts that call - a name past the deadline would both answer 
`REQUEST_TIMED_OUT` *and*
+/// actually delete the topic. The `Instant::now() >= deadline` check below 
must run and must
+/// skip the call itself, the same pattern `list_offsets.rs` uses, not rely on 
`timeout_at` to
+/// skip it.
+async fn delete_all_topics(
+    bridge: &IggyBridge,
+    topic_names: &[TopicName],
+    duplicate_names: &HashSet<TopicName>,
+    deadline: Instant,
+) -> Vec<DeletableTopicResult> {
+    let mut results = Vec::with_capacity(topic_names.len());
+    let mut deadline_exceeded = false;
+    for name in topic_names {
+        if duplicate_names.contains(name) {
+            results.push(
+                DeletableTopicResult::default()
+                    .with_name(Some(name.clone()))
+                    .with_error_code(ERROR_INVALID_REQUEST)
+                    .with_error_message(None),
+            );
+            continue;
+        }
+        if deadline_exceeded || Instant::now() >= deadline {
+            if !deadline_exceeded {
+                deadline_exceeded = true;
+                tracing::warn!(
+                    "DeleteTopics deadline passed; answering remaining topics 
retriable \
+                     instead of starting new Iggy calls"
+                );
+            }
+            results.push(
+                DeletableTopicResult::default()
+                    .with_name(Some(name.clone()))
+                    .with_error_code(ERROR_REQUEST_TIMED_OUT)
+                    .with_error_message(None),
+            );
+            continue;
+        }
+
+        let result = match tokio::time::timeout_at(deadline, 
delete_one_topic(bridge, name)).await {
+            Ok(result) => result,
+            Err(_elapsed) => {
+                deadline_exceeded = true;
+                tracing::warn!(
+                    kafka_topic = name.as_str(),
+                    "DeleteTopics: this topic's bridge work exceeded the 
request deadline; \
+                     answering retriable instead of blocking further"
+                );
+                DeletableTopicResult::default()
+                    .with_name(Some(name.clone()))
+                    .with_error_code(ERROR_REQUEST_TIMED_OUT)
+                    .with_error_message(None)
+            }
+        };
+        results.push(result);
+    }
+    results
+}
+
+async fn delete_one_topic(bridge: &IggyBridge, name: &TopicName) -> 
DeletableTopicResult {
+    let result = DeletableTopicResult::default()
+        .with_name(Some(name.clone()))
+        .with_error_message(None);
+    match bridge.delete_kafka_topic(name.as_str()).await {
+        // The second arm: the write committed on its first attempt, the SDK's 
own reconnect path
+        // replayed it, and the server's client-table dedup caught the replay 
- not a fault. Same
+        // reasoning as `create_topics::create_one_topic`'s identical arm.
+        Ok(()) | Err(BridgeError::Iggy(IggyError::RequestAlreadyApplied)) => {
+            result.with_error_code(ERROR_NONE)
+        }
+        Err(err) => bridge_error_result(result, name.as_str(), &err),
+    }
+}
+
+/// Maps a bridge failure to a Kafka result, logging the real cause 
server-side.
+///
+/// Same caution as `create_topics::bridge_error_result` on forwarding 
`err.to_string()`: a
+/// server-side rejection can reconstruct with default-valued fields, so only 
the client-caused
+/// variant (an invalid name) forwards its own text. A missing topic and an 
unauthorized reply
+/// are routine, so they log at `debug!` with fixed text. Everything else gets 
a fixed message
+/// and logs at `error!`.
+fn bridge_error_result(
+    result: DeletableTopicResult,
+    kafka_topic: &str,
+    err: &BridgeError,
+) -> DeletableTopicResult {
+    let error_code = err.to_kafka_error_code();
+    match err {
+        BridgeError::InvalidKafkaTopicName { reason, .. } => {
+            tracing::debug!(
+                kafka_topic,
+                reason,
+                "DeleteTopics rejected an invalid topic name"
+            );
+            result
+                .with_error_code(error_code)
+                .with_error_message(Some(StrBytes::from(reason.clone())))
+        }
+        BridgeError::Iggy(
+            IggyError::StreamIdNotFound(_)
+            | IggyError::StreamNameNotFound(_)
+            | IggyError::TopicIdNotFound(_, _)
+            | IggyError::TopicNameNotFound(_, _)
+            | IggyError::PartitionNotFound(_, _, _)
+            | IggyError::ResourceNotFound(_),
+        ) => {
+            tracing::debug!(kafka_topic, error = %err, "DeleteTopics: topic 
does not exist");
+            result
+                .with_error_code(error_code)
+                .with_error_message(Some(StrBytes::from(
+                    "this topic does not exist".to_string(),
+                )))
+        }
+        BridgeError::Iggy(IggyError::Unauthorized) => {
+            tracing::debug!(
+                kafka_topic,
+                error = %err,
+                "DeleteTopics: not authorized to delete this topic"
+            );
+            result
+                .with_error_code(error_code)
+                .with_error_message(Some(StrBytes::from(
+                    "not authorized to delete this topic".to_string(),
+                )))
+        }
+        other => {
+            tracing::error!(kafka_topic, %other, "DeleteTopics failed against 
the Iggy bridge");
+            result
+                .with_error_code(error_code)
+                .with_error_message(Some(StrBytes::from(
+                    "internal error deleting this topic".to_string(),
+                )))
+        }
+    }
+}
+
+/// Well-formed `DeleteTopics` response naming every requested topic.
+///
+/// # Errors
+///
+/// Returns an error when `kafka_protocol` cannot encode the response at 
`version`.
+pub fn encode_error_response(version: i16, error_code: i16) -> Result<Bytes> {
+    encode_inner(version, &[], error_code)
+}
+
+/// # Errors
+///
+/// Returns an error when `kafka_protocol` cannot encode the response at 
`version`.
+pub fn encode_response(version: i16, req: &DeleteTopicsRequest) -> 
Result<Bytes> {
+    encode_inner(version, &req.topic_names, ERROR_NOT_CONTROLLER)
+}
+
+fn encode_inner(version: i16, topic_names: &[TopicName], forced_error: i16) -> 
Result<Bytes> {
+    let results = topic_names
+        .iter()
+        .map(|name| {
+            DeletableTopicResult::default()
+                .with_name(Some(name.clone()))
+                .with_error_code(forced_error)
+                .with_error_message(None)
+        })
+        .collect();
+    let resp = DeleteTopicsResponse::default().with_responses(results);
+    encode_message(&resp, version, 256)
+}
+
+#[cfg(test)]
+mod tests {
+    use iggy::prelude::Identifier;
+
+    use super::*;
+
+    #[test]
+    fn bridge_error_result_names_a_missing_topic_and_an_unauthorized_reply() {
+        let missing_stream = 
BridgeError::Iggy(IggyError::StreamIdNotFound(Identifier::default()));
+        let missing_topic = BridgeError::Iggy(IggyError::TopicIdNotFound(
+            Identifier::default(),
+            Identifier::default(),
+        ));
+        let unauthorized = BridgeError::Iggy(IggyError::Unauthorized);
+        let cases = [
+            (&missing_stream, "this topic does not exist"),
+            (&missing_topic, "this topic does not exist"),
+            (&unauthorized, "not authorized to delete this topic"),
+        ];
+        for (err, expected) in cases {
+            let result = bridge_error_result(DeletableTopicResult::default(), 
"orders", err);
+            assert_eq!(result.error_code, err.to_kafka_error_code());
+            assert_eq!(result.error_message.as_deref(), Some(expected));
+        }
+    }
+}
diff --git a/gateways/kafka/src/protocol/handlers/mod.rs 
b/gateways/kafka/src/protocol/handlers/mod.rs
index ea706f221..499b497f4 100644
--- a/gateways/kafka/src/protocol/handlers/mod.rs
+++ b/gateways/kafka/src/protocol/handlers/mod.rs
@@ -27,6 +27,7 @@
 pub mod alter_configs;
 pub mod api_versions;
 pub mod create_topics;
+pub mod delete_topics;
 pub mod describe_configs;
 pub mod fetch;
 pub mod find_coordinator;
@@ -42,6 +43,8 @@ pub mod produce;
 pub mod sync_group;
 pub(crate) mod topic_config;
 
+use std::time::Duration;
+
 use bytes::{Buf, Bytes, BytesMut};
 use iggy::prelude::IggyError;
 use kafka_protocol::messages::TransactionalId;
@@ -53,12 +56,12 @@ use crate::auth::AuthenticatedPrincipal;
 use crate::bridge::BridgeError;
 use crate::error::{KafkaProtocolError, Result};
 use crate::protocol::api::{
-    API_KEY_ALTER_CONFIGS, API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, 
API_KEY_DESCRIBE_CONFIGS,
-    API_KEY_FETCH, API_KEY_FIND_COORDINATOR, API_KEY_HEARTBEAT, 
API_KEY_INIT_PRODUCER_ID,
-    API_KEY_JOIN_GROUP, API_KEY_LEAVE_GROUP, API_KEY_LIST_OFFSETS, 
API_KEY_METADATA,
-    API_KEY_OFFSET_COMMIT, API_KEY_OFFSET_FETCH, API_KEY_PRODUCE, 
API_KEY_SYNC_GROUP,
-    ConnectionState, ERROR_INVALID_REQUEST, ERROR_UNSUPPORTED_VERSION, 
GatewayState, HandleOutcome,
-    is_supported_version, supported_max_version,
+    API_KEY_ALTER_CONFIGS, API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, 
API_KEY_DELETE_TOPICS,
+    API_KEY_DESCRIBE_CONFIGS, API_KEY_FETCH, API_KEY_FIND_COORDINATOR, 
API_KEY_HEARTBEAT,
+    API_KEY_INIT_PRODUCER_ID, API_KEY_JOIN_GROUP, API_KEY_LEAVE_GROUP, 
API_KEY_LIST_OFFSETS,
+    API_KEY_METADATA, API_KEY_OFFSET_COMMIT, API_KEY_OFFSET_FETCH, 
API_KEY_PRODUCE,
+    API_KEY_SYNC_GROUP, ConnectionState, ERROR_INVALID_REQUEST, 
ERROR_UNSUPPORTED_VERSION,
+    GatewayState, HandleOutcome, is_supported_version, supported_max_version,
 };
 
 /// Record encodes and decodes of this many bytes or more run off the async 
worker.
@@ -66,6 +69,20 @@ pub(crate) const CODEC_OFF_WORKER_BYTES: usize = 64 * 1024;
 /// A plain copy costs less per byte, so the handoff pays off only from this 
size.
 pub(crate) const COPY_OFF_WORKER_BYTES: usize = 1024 * 1024;
 
+/// Client-supplied `timeout_ms` floor. `0` or negative would abort every 
topic on arrival.
+pub(crate) const MIN_REQUEST_TIMEOUT: Duration = Duration::from_millis(1_000);
+/// Client-supplied `timeout_ms` ceiling. Above this the shared `IggyClient` 
stays busy too long.
+pub(crate) const MAX_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Clamps KIP-4 `timeout_ms` into `[MIN_REQUEST_TIMEOUT, 
MAX_REQUEST_TIMEOUT]`.
+///
+/// `CreateTopics` and `DeleteTopics` honor the request's own deadline. 
`ListOffsets` and
+/// `Metadata` do not: they have no such field.
+pub(crate) fn clamp_request_timeout(timeout_ms: i32) -> Duration {
+    let requested = 
Duration::from_millis(u64::try_from(timeout_ms).unwrap_or(0));
+    requested.clamp(MIN_REQUEST_TIMEOUT, MAX_REQUEST_TIMEOUT)
+}
+
 /// Runs `work`. When `heavy`, other tasks move to another worker first, so 
they keep running. A
 /// current-thread runtime runs it in place.
 pub(crate) fn off_worker<T>(heavy: bool, work: impl FnOnce() -> T) -> T {
@@ -99,6 +116,7 @@ pub async fn dispatch(
         API_KEY_OFFSET_FETCH => offset_fetch::handle(state, connection, 
api_version, body).await,
         API_KEY_API_VERSIONS => api_versions::handle(state, api_version, 
body).await,
         API_KEY_CREATE_TOPICS => create_topics::handle(state, api_version, 
body).await,
+        API_KEY_DELETE_TOPICS => delete_topics::handle(state, principal, 
api_version, body).await,
         API_KEY_DESCRIBE_CONFIGS => describe_configs::handle(state, 
api_version, body).await,
         API_KEY_ALTER_CONFIGS => alter_configs::handle(state, principal, 
api_version, body).await,
         API_KEY_FIND_COORDINATOR => find_coordinator::handle(state, 
api_version, body).await,
@@ -289,4 +307,21 @@ mod tests {
     async fn given_a_multi_thread_runtime_when_work_is_large_should_run_it() {
         assert_eq!(off_worker(true, || 7), 7);
     }
+
+    #[test]
+    fn 
clamp_request_timeout_rejects_a_zero_or_negative_value_up_to_the_floor() {
+        assert_eq!(clamp_request_timeout(0), MIN_REQUEST_TIMEOUT);
+        assert_eq!(clamp_request_timeout(-1), MIN_REQUEST_TIMEOUT);
+        assert_eq!(clamp_request_timeout(i32::MIN), MIN_REQUEST_TIMEOUT);
+    }
+
+    #[test]
+    fn clamp_request_timeout_caps_an_oversized_value_at_the_ceiling() {
+        assert_eq!(clamp_request_timeout(i32::MAX), MAX_REQUEST_TIMEOUT);
+    }
+
+    #[test]
+    fn clamp_request_timeout_passes_through_a_reasonable_value_unchanged() {
+        assert_eq!(clamp_request_timeout(5_000), Duration::from_secs(5));
+    }
 }
diff --git a/gateways/kafka/tests/api_handler_tests.rs 
b/gateways/kafka/tests/api_handler_tests.rs
index d007221bb..eff5ff440 100644
--- a/gateways/kafka/tests/api_handler_tests.rs
+++ b/gateways/kafka/tests/api_handler_tests.rs
@@ -29,10 +29,10 @@ mod wire;
 use bytes::Bytes;
 
 use iggy_gateway_kafka::protocol::api::{
-    API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, API_KEY_FETCH, 
API_KEY_LIST_OFFSETS,
-    API_KEY_METADATA, API_KEY_PRODUCE, ERROR_INVALID_REQUEST, ERROR_NONE, 
ERROR_NOT_CONTROLLER,
-    ERROR_NOT_LEADER_OR_FOLLOWER, ERROR_UNKNOWN_TOPIC_OR_PARTITION, 
ERROR_UNSUPPORTED_VERSION,
-    handle_request, is_supported_version, supported_api_ranges,
+    API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, API_KEY_DELETE_TOPICS, 
API_KEY_FETCH,
+    API_KEY_LIST_OFFSETS, API_KEY_METADATA, API_KEY_PRODUCE, 
ERROR_INVALID_REQUEST, ERROR_NONE,
+    ERROR_NOT_CONTROLLER, ERROR_NOT_LEADER_OR_FOLLOWER, 
ERROR_UNKNOWN_TOPIC_OR_PARTITION,
+    ERROR_UNSUPPORTED_VERSION, handle_request, is_supported_version, 
supported_api_ranges,
 };
 
 use codec::Decoder;
@@ -40,9 +40,10 @@ use fixtures::load_fixture_body_or_skip;
 use scope::default_broker;
 use tcp::{build_metadata_legacy_request, build_produce_v3_body};
 use wire::{
-    build_api_versions_flexible_request, build_metadata_all_topics_legacy,
-    build_metadata_flexible_request, build_metadata_flexible_request_v10,
-    build_metadata_legacy_request_for_version, build_produce_legacy_request,
+    build_api_versions_flexible_request, build_delete_topics_request_with_name,
+    build_metadata_all_topics_legacy, build_metadata_flexible_request,
+    build_metadata_flexible_request_v10, 
build_metadata_legacy_request_for_version,
+    build_produce_legacy_request,
 };
 
 // ── ApiVersions ─────────────────────────────────────────────────────────────
@@ -808,6 +809,49 @@ async fn 
create_topics_decodes_request_with_real_topic_and_assignment() {
     assert_eq!(d.read_i16().unwrap(), ERROR_NOT_CONTROLLER);
 }
 
+/// Pins the `flexible = version >= 4` boundary `validate_delete_topics_shape` 
and
+/// `encode_delete_topics_response` both rely on, with a real (non-empty) 
topic name so the
+/// per-topic error code is actually observable in the response - an empty 
request's response
+/// has zero entries, so no error code in it is ever asserted anywhere else. 
v1 and v3 are
+/// legacy; v4 and v5 are flexible (v4 has no `error_message` field, v5 does).
+#[tokio::test]
+async fn 
delete_topics_stub_response_returns_not_controller_per_version_shape() {
+    for version in [1i16, 3, 4, 5] {
+        let body = build_delete_topics_request_with_name(version, "orders");
+        let resp = handle_request(API_KEY_DELETE_TOPICS, version, body, 
&default_broker())
+            .await
+            .expect_response("test request has acks != 0 and expects a 
response");
+        let flexible = version >= 4;
+        let mut d = Decoder::new(resp);
+        let _throttle = d.read_i32().unwrap();
+        if flexible {
+            let responses_plus_one = d.read_varint().unwrap();
+            assert_eq!(
+                responses_plus_one, 2,
+                "DeleteTopics v{version}: one response"
+            );
+            assert_eq!(
+                d.read_compact_nullable_string().unwrap(),
+                Some("orders".to_string()),
+                "DeleteTopics v{version}: name must echo the request"
+            );
+        } else {
+            let responses = d.read_i32().unwrap();
+            assert_eq!(responses, 1, "DeleteTopics v{version}: one response");
+            assert_eq!(
+                d.read_nullable_string().unwrap(),
+                Some("orders".to_string()),
+                "DeleteTopics v{version}: name must echo the request"
+            );
+        }
+        assert_eq!(
+            d.read_i16().unwrap(),
+            ERROR_NOT_CONTROLLER,
+            "DeleteTopics v{version}"
+        );
+    }
+}
+
 // ── Produce acks=0 with a malformed body closes ──────
 
 #[tokio::test]
diff --git a/gateways/kafka/tests/common/scope.rs 
b/gateways/kafka/tests/common/scope.rs
index 4a9153713..63b4f38b9 100644
--- a/gateways/kafka/tests/common/scope.rs
+++ b/gateways/kafka/tests/common/scope.rs
@@ -39,6 +39,7 @@ pub const SCOPED_API_KEYS: &[(i16, &str, i16, i16)] = &[
     (14, "SyncGroup", 0, 5),
     (18, "ApiVersions", 0, 3),
     (19, "CreateTopics", 2, 5),
+    (20, "DeleteTopics", 1, 5),
     (22, "InitProducerId", 0, 5),
     (32, "DescribeConfigs", 1, 4),
     (33, "AlterConfigs", 0, 2),
diff --git a/gateways/kafka/tests/common/wire.rs 
b/gateways/kafka/tests/common/wire.rs
index dc3b838a5..14c56db76 100644
--- a/gateways/kafka/tests/common/wire.rs
+++ b/gateways/kafka/tests/common/wire.rs
@@ -45,11 +45,7 @@ pub fn decode<M: Decodable>(body: Bytes, version: i16) -> M {
 }
 
 /// Consumer-group and admin keys explicitly out of scope in SCOPE.md.
-pub const OUT_OF_SCOPE_API_KEYS: &[(i16, &str)] = &[
-    (15, "DescribeGroups"),
-    (16, "ListGroups"),
-    (20, "DeleteTopics"),
-];
+pub const OUT_OF_SCOPE_API_KEYS: &[(i16, &str)] = &[(15, "DescribeGroups"), 
(16, "ListGroups")];
 
 /// Append Metadata request fields that follow the topics array for `version`.
 fn write_metadata_request_trailer(enc: &mut Encoder, version: i16) {
@@ -190,6 +186,47 @@ pub fn build_create_topics_empty_request(version: i16) -> 
Bytes {
     enc.freeze()
 }
 
+/// Empty `DeleteTopics` request (no topic names) for supported versions 
(v1-v5).
+pub fn build_delete_topics_empty_request(version: i16) -> Bytes {
+    let flexible = version >= 4;
+    let mut enc = Encoder::with_capacity(16);
+
+    if flexible {
+        enc.write_varint(1); // empty topic_names compact array (N+1 = 1)
+    } else {
+        enc.write_i32(0);
+    }
+    enc.write_i32(5_000); // timeout_ms
+    if flexible {
+        enc.write_empty_tagged_fields();
+    }
+
+    enc.freeze()
+}
+
+/// `DeleteTopics` request naming one topic, for supported versions (v1-v5).
+///
+/// Unlike [`build_delete_topics_empty_request`], this actually produces a 
per-topic result -
+/// an empty request's response has zero entries, so no error code in it is 
ever observed.
+pub fn build_delete_topics_request_with_name(version: i16, name: &str) -> 
Bytes {
+    let flexible = version >= 4;
+    let mut enc = Encoder::with_capacity(32);
+
+    if flexible {
+        enc.write_varint(2); // one topic_names entry (N+1 = 2)
+        enc.write_compact_nullable_string(Some(name));
+    } else {
+        enc.write_i32(1);
+        enc.write_nullable_string(Some(name)).expect("name fits");
+    }
+    enc.write_i32(5_000); // timeout_ms
+    if flexible {
+        enc.write_empty_tagged_fields();
+    }
+
+    enc.freeze()
+}
+
 /// Produce v2–v8 legacy request with optional transactional id and topic.
 pub fn build_produce_legacy_request(
     version: i16,
diff --git a/gateways/kafka/tests/delete_topics_real_bridge_tests.rs 
b/gateways/kafka/tests/delete_topics_real_bridge_tests.rs
new file mode 100644
index 000000000..f125d3f2a
--- /dev/null
+++ b/gateways/kafka/tests/delete_topics_real_bridge_tests.rs
@@ -0,0 +1,336 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Wire-level `DeleteTopics` tests against a real `iggy-server` process, 
through
+//! [`delete_topics::handle`] with a connected [`GatewayState`] - same shape as
+//! `create_topics_real_bridge_tests.rs`. Exercises the handler's own 
orchestration (the
+//! topic-cap check, per-topic independent deletion, real error codes reaching 
the wire) and,
+//! critically, the deliberate choice this bridge makes not to delete the 
backing Iggy stream.
+//!
+//! Requests are hand-built at the v5 flexible wire shape, for the same reason
+//! `create_topics_real_bridge_tests.rs` hand-builds its own: this crate 
builds with
+//! `default-features = false, features = ["broker"]`, which gives request 
`Decodable` and
+//! response `Encodable` but not the reverse.
+
+use std::sync::Arc;
+
+use bytes::Bytes;
+use iggy::prelude::{Identifier, StreamClient, TopicClient};
+use serial_test::serial;
+use tokio_util::sync::CancellationToken;
+
+use iggy_gateway_kafka::bridge::IggyBridge;
+use iggy_gateway_kafka::group::{GroupCoordinator, GroupCoordinatorConfig};
+use iggy_gateway_kafka::protocol::api::{
+    BrokerAdvertise, ERROR_INVALID_REQUEST, ERROR_NONE, ERROR_POLICY_VIOLATION,
+    ERROR_UNKNOWN_TOPIC_OR_PARTITION, GatewayState,
+};
+use iggy_gateway_kafka::protocol::handlers::delete_topics;
+
+#[path = "common/codec.rs"]
+mod codec;
+#[path = "common/iggy_server.rs"]
+mod iggy_server;
+
+use codec::{Decoder, Encoder};
+use iggy_server::{TestServer, raw_client};
+
+const REQUEST_VERSION: i16 = 5;
+const TEST_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024;
+
+/// Builds a v5 flexible `DeleteTopics` request body for one or more topic 
names.
+fn build_request(names: &[&str]) -> Bytes {
+    let mut enc = Encoder::with_capacity(128);
+    enc.write_varint((names.len() + 1) as u64);
+    for name in names {
+        enc.write_compact_nullable_string(Some(name));
+    }
+    enc.write_i32(5_000); // timeout_ms
+    enc.write_empty_tagged_fields();
+    enc.freeze()
+}
+
+/// Decodes every topic result in a v5 flexible `DeleteTopics` response into 
`(name, error_code)`,
+/// in wire order.
+fn decode_all_results(body: Bytes) -> Vec<(Option<String>, i16)> {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+    let results_plus_one = d.read_varint().expect("responses array count");
+    let mut results = Vec::new();
+    for _ in 1..results_plus_one {
+        let name = d.read_compact_nullable_string().expect("topic name");
+        let error_code = d.read_i16().expect("error_code");
+        let _error_message = 
d.read_compact_nullable_string().expect("error_message");
+        let _tagged = d.read_varint().expect("topic tagged fields");
+        results.push((name, error_code));
+    }
+    results
+}
+
+/// Decodes the first (only) topic result into `(error_code, error_message)`.
+fn decode_first_result(body: Bytes) -> (i16, Option<String>) {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+    let _results_plus_one = d.read_varint().expect("responses array count");
+    let _name = d.read_compact_nullable_string().expect("topic name");
+    let error_code = d.read_i16().expect("error_code");
+    let error_message = 
d.read_compact_nullable_string().expect("error_message");
+    (error_code, error_message)
+}
+
+async fn send(state: &GatewayState, names: &[&str]) -> Vec<(Option<String>, 
i16)> {
+    let body = build_request(names);
+    let outcome = delete_topics::handle(state, None, REQUEST_VERSION, 
body).await;
+    let resp_body = outcome.expect_response("DeleteTopics request always 
answers");
+    decode_all_results(resp_body)
+}
+
+async fn connected_state(server: &TestServer) -> GatewayState {
+    let bridge = IggyBridge::connect(server.test_config())
+        .await
+        .expect("bridge should connect to a ready server");
+    GatewayState::new(
+        BrokerAdvertise::default(),
+        Some(Arc::new(bridge)),
+        TEST_MAX_FRAME_SIZE,
+        false,
+        0,
+        GroupCoordinator::new(GroupCoordinatorConfig::default(), 
CancellationToken::new()),
+    )
+}
+
+#[tokio::test]
+#[serial]
+async fn delete_topics_deletes_a_real_iggy_topic() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+    let Some(bridge) = &state.bridge else {
+        panic!("bridge must be connected")
+    };
+    bridge
+        .create_kafka_topic("orders", 3)
+        .await
+        .expect("create the topic to delete");
+
+    let results = send(&state, &["orders"]).await;
+    assert_eq!(results, vec![(Some("orders".to_string()), ERROR_NONE)]);
+
+    let raw = raw_client(&server).await;
+    let topics = raw
+        .get_topics(&Identifier::named("kafka").expect("valid stream name"))
+        .await
+        .expect("get_topics call");
+    assert!(
+        topics.is_empty(),
+        "the handler must have actually deleted the topic"
+    );
+}
+
+/// The design decision this PR made deliberately (not the issue's literal 
"delete stream +
+/// topic" wording): `DeleteTopics` must never delete the backing Iggy stream, 
since the shared
+/// `default_stream` can hold other Kafka topics' data this bridge has no way 
to distinguish from
+/// an abandoned one. Verified here by creating two topics in the same 
(default) stream, deleting
+/// one, and confirming the stream - and the other topic in it - are both 
still there.
+#[tokio::test]
+#[serial]
+async fn 
delete_topics_never_deletes_the_backing_stream_even_when_it_becomes_empty() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+    let Some(bridge) = &state.bridge else {
+        panic!("bridge must be connected")
+    };
+    bridge
+        .create_kafka_topic("orders", 1)
+        .await
+        .expect("create orders");
+    bridge
+        .create_kafka_topic("payments", 1)
+        .await
+        .expect("create payments");
+
+    // Delete both topics sharing the default stream - if this ever deleted 
the stream once it
+    // emptied out, the second delete (or the stream-existence check below) 
would fail.
+    let results = send(&state, &["orders", "payments"]).await;
+    assert_eq!(
+        results,
+        vec![
+            (Some("orders".to_string()), ERROR_NONE),
+            (Some("payments".to_string()), ERROR_NONE),
+        ]
+    );
+
+    let raw = raw_client(&server).await;
+    let streams = raw.get_streams().await.expect("get_streams call");
+    assert_eq!(
+        streams.len(),
+        1,
+        "the now-empty default stream must still exist - DeleteTopics never 
touches it"
+    );
+}
+
+#[tokio::test]
+#[serial]
+async fn 
delete_topics_on_a_nonexistent_topic_returns_unknown_topic_or_partition() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+
+    let results = send(&state, &["never-existed"]).await;
+    assert_eq!(
+        results,
+        vec![(
+            Some("never-existed".to_string()),
+            ERROR_UNKNOWN_TOPIC_OR_PARTITION
+        )]
+    );
+}
+
+/// A name repeated in the same request is rejected outright for every 
occurrence, the same
+/// choice `CreateTopics` makes for a repeated topic name (real Kafka's 
`ControllerApis.deleteTopics`
+/// answers `INVALID_REQUEST`(42) "Duplicate topic name" for every occurrence 
and deletes nothing -
+/// a first-occurrence-wins split would let an `AdminClient` caller observe a 
delete it never got
+/// a clean answer for, since the client keys its futures by name and silently 
discards the second
+/// per-name result regardless of which occurrence this bridge picked).
+#[tokio::test]
+#[serial]
+async fn delete_topics_every_occurrence_of_a_duplicate_name_is_rejected() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+    let Some(bridge) = &state.bridge else {
+        panic!("bridge must be connected")
+    };
+    bridge
+        .create_kafka_topic("orders", 1)
+        .await
+        .expect("create orders");
+
+    let results = send(&state, &["orders", "orders"]).await;
+    assert_eq!(
+        results,
+        vec![
+            (Some("orders".to_string()), ERROR_INVALID_REQUEST),
+            (Some("orders".to_string()), ERROR_INVALID_REQUEST),
+        ]
+    );
+
+    // Neither occurrence deleted anything - the topic must still exist.
+    let topic = bridge
+        .get_kafka_topic("orders")
+        .await
+        .expect("lookup should not fail");
+    assert!(
+        topic.is_some(),
+        "a duplicate name must not delete the topic it names"
+    );
+}
+
+/// One missing topic in a batch must not block an unrelated, existing topic 
from being deleted.
+#[tokio::test]
+#[serial]
+async fn delete_topics_deletes_existing_topics_alongside_a_missing_one() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+    let Some(bridge) = &state.bridge else {
+        panic!("bridge must be connected")
+    };
+    bridge
+        .create_kafka_topic("orders", 1)
+        .await
+        .expect("create orders");
+
+    let results = send(&state, &["orders", "never-existed"]).await;
+    assert_eq!(
+        results,
+        vec![
+            (Some("orders".to_string()), ERROR_NONE),
+            (
+                Some("never-existed".to_string()),
+                ERROR_UNKNOWN_TOPIC_OR_PARTITION
+            ),
+        ]
+    );
+
+    let raw = raw_client(&server).await;
+    let topics = raw
+        .get_topics(&Identifier::named("kafka").expect("valid stream name"))
+        .await
+        .expect("get_topics call");
+    assert!(topics.is_empty(), "orders must have actually been deleted");
+}
+
+/// Regression test: a request naming more than the bridge-backed topic cap 
must be rejected
+/// wholesale (every entry, `POLICY_VIOLATION`, nothing deleted) rather than 
partially served -
+/// same contract `create_topics_rejects_more_than_the_topic_cap` holds for 
`CreateTopics`.
+#[tokio::test]
+#[serial]
+async fn delete_topics_rejects_more_than_the_topic_cap() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+    let Some(bridge) = &state.bridge else {
+        panic!("bridge must be connected")
+    };
+    bridge
+        .create_kafka_topic("orders", 1)
+        .await
+        .expect("create orders");
+
+    let names: Vec<String> = (0..101).map(|i| format!("topic-{i}")).collect();
+    let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
+    let results = send(&state, &name_refs).await;
+    assert_eq!(results.len(), 101);
+    for (_, error_code) in &results {
+        assert_eq!(*error_code, ERROR_POLICY_VIOLATION);
+    }
+
+    let raw = raw_client(&server).await;
+    let topics = raw
+        .get_topics(&Identifier::named("kafka").expect("valid stream name"))
+        .await
+        .expect("get_topics call");
+    assert_eq!(
+        topics.len(),
+        1,
+        "an over-cap request must delete nothing, including topics not named 
in it"
+    );
+}
+
+/// `error_message` carries the real cause for a client-visible rejection path 
(the topic-cap
+/// policy violation), not just a bare code.
+#[tokio::test]
+#[serial]
+async fn delete_topics_over_cap_error_message_names_the_limit() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let state = connected_state(&server).await;
+
+    let names: Vec<String> = (0..101).map(|i| format!("topic-{i}")).collect();
+    let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
+    let body = build_request(&name_refs);
+    let outcome = delete_topics::handle(&state, None, REQUEST_VERSION, 
body).await;
+    let resp_body = outcome.expect_response("DeleteTopics request always 
answers");
+    let (error_code, error_message) = decode_first_result(resp_body);
+    assert_eq!(error_code, ERROR_POLICY_VIOLATION);
+    assert!(
+        error_message.is_some_and(|m| m.contains("100")),
+        "error_message must name the actual cap"
+    );
+}
diff --git a/gateways/kafka/tests/golden_wire_fixtures_tests.rs 
b/gateways/kafka/tests/golden_wire_fixtures_tests.rs
index b020d6ea0..d1e763690 100644
--- a/gateways/kafka/tests/golden_wire_fixtures_tests.rs
+++ b/gateways/kafka/tests/golden_wire_fixtures_tests.rs
@@ -41,11 +41,11 @@ async fn golden_apiversions_v3_flexible_response_fixture() {
         .await
         .expect_response("test request has acks != 0 and expects a response");
 
-    // error_code=0, api_count=16 (compact array: N+1=17)
+    // error_code=0, api_count=17 (compact array: N+1=18)
     // each entry followed by an empty tagged-fields byte; throttle_ms=0; 
top-level tagged fields
-    let expected: [u8; 120] = [
+    let expected: [u8; 127] = [
         0x00, 0x00, // error_code
-        0x11, // compact array count (16+1)
+        0x12, // compact array count (17+1)
         0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, // key 0:  Produce         
0-9 (advertised)
         0x00, 0x01, 0x00, 0x04, 0x00, 0x0C, 0x00, // key 1:  Fetch           
4-12
         0x00, 0x02, 0x00, 0x01, 0x00, 0x06, 0x00, // key 2:  ListOffsets     
1-6
@@ -59,6 +59,7 @@ async fn golden_apiversions_v3_flexible_response_fixture() {
         0x00, 0x0E, 0x00, 0x00, 0x00, 0x05, 0x00, // key 14: SyncGroup       
0-5
         0x00, 0x12, 0x00, 0x00, 0x00, 0x03, 0x00, // key 18: ApiVersions     
0-3
         0x00, 0x13, 0x00, 0x02, 0x00, 0x05, 0x00, // key 19: CreateTopics    
2-5
+        0x00, 0x14, 0x00, 0x01, 0x00, 0x05, 0x00, // key 20: DeleteTopics    
1-5
         0x00, 0x16, 0x00, 0x00, 0x00, 0x05, 0x00, // key 22: InitProducerId  
0-5
         0x00, 0x20, 0x00, 0x01, 0x00, 0x04, 0x00, // key 32: DescribeConfigs 
1-4
         0x00, 0x21, 0x00, 0x00, 0x00, 0x02, 0x00, // key 33: AlterConfigs    
0-2
@@ -75,10 +76,10 @@ async fn golden_apiversions_v1_response_fixture() {
         .await
         .expect_response("test request has acks != 0 and expects a response");
 
-    // error_code=0, api_count=16; Produce advertises min=0 per KAFKA-18659; 
throttle_ms=0
-    let expected: [u8; 106] = [
+    // error_code=0, api_count=17; Produce advertises min=0 per KAFKA-18659; 
throttle_ms=0
+    let expected: [u8; 112] = [
         0x00, 0x00, // error_code
-        0x00, 0x00, 0x00, 0x10, // api count = 16
+        0x00, 0x00, 0x00, 0x11, // api count = 17
         0x00, 0x00, 0x00, 0x00, 0x00, 0x09, // key 0:  Produce         0-9 
(advertised)
         0x00, 0x01, 0x00, 0x04, 0x00, 0x0C, // key 1:  Fetch           4-12
         0x00, 0x02, 0x00, 0x01, 0x00, 0x06, // key 2:  ListOffsets     1-6
@@ -92,6 +93,7 @@ async fn golden_apiversions_v1_response_fixture() {
         0x00, 0x0E, 0x00, 0x00, 0x00, 0x05, // key 14: SyncGroup       0-5
         0x00, 0x12, 0x00, 0x00, 0x00, 0x03, // key 18: ApiVersions     0-3
         0x00, 0x13, 0x00, 0x02, 0x00, 0x05, // key 19: CreateTopics    2-5
+        0x00, 0x14, 0x00, 0x01, 0x00, 0x05, // key 20: DeleteTopics    1-5
         0x00, 0x16, 0x00, 0x00, 0x00, 0x05, // key 22: InitProducerId  0-5
         0x00, 0x20, 0x00, 0x01, 0x00, 0x04, // key 32: DescribeConfigs 1-4
         0x00, 0x21, 0x00, 0x00, 0x00, 0x02, // key 33: AlterConfigs    0-2
diff --git a/gateways/kafka/tests/kafka_client_e2e_tests.rs 
b/gateways/kafka/tests/kafka_client_e2e_tests.rs
index 35cc5d713..ce835c239 100644
--- a/gateways/kafka/tests/kafka_client_e2e_tests.rs
+++ b/gateways/kafka/tests/kafka_client_e2e_tests.rs
@@ -1010,6 +1010,77 @@ fn list_acls(gateway: SocketAddr, username: &str, 
password: &str) -> String {
     .expect_ran(&format!("listing ACLs as {username}"))
 }
 
+/// Runs `kafka-topics.sh` with `args` against `gateway`. No 
`--command-config`: the bridged
+/// stack this test uses runs with SASL off, same as the kcat calls against it 
above.
+fn kafka_topics(gateway: SocketAddr, args: &[&str]) -> ClientRun {
+    let bootstrap = gateway.to_string();
+    let mut full_args: Vec<&str> = vec![
+        "/opt/kafka/bin/kafka-topics.sh",
+        "--bootstrap-server",
+        &bootstrap,
+    ];
+    full_args.extend_from_slice(args);
+    run_client(KAFKA_IMAGE, &full_args, &[])
+}
+
+/// #3546's own acceptance criterion: `kafka-topics.sh` must work against the 
gateway for create
+/// and delete. (`--describe` needs `DescribeConfigs`, not yet implemented, so 
it is not covered
+/// here.) Runs against the bridged stack, since this exercises the real 
`CreateTopics`/
+/// `DeleteTopics` bridge path, not authentication - the other Java-client 
tests above already
+/// cover SASL.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
given_kafka_topics_sh_when_creating_then_deleting_a_topic_should_round_trip_through_the_bridge()
+ {
+    const NEW_TOPIC: &str = "kafka-topics-roundtrip";
+    let Some((_server, gateway)) = bridged_stack().await else {
+        return;
+    };
+
+    let created = kafka_topics(
+        gateway,
+        &[
+            "--create",
+            "--topic",
+            NEW_TOPIC,
+            "--partitions",
+            "3",
+            "--replication-factor",
+            "1",
+        ],
+    )
+    .expect_ran("kafka-topics.sh --create");
+    assert!(
+        created.contains(&format!("Created topic {NEW_TOPIC}")),
+        "kafka-topics.sh must report the topic created, got: {created}"
+    );
+
+    let listed =
+        kafka_topics(gateway, &["--list"]).expect_ran("kafka-topics.sh --list 
after create");
+    assert!(
+        listed.lines().any(|line| line.trim() == NEW_TOPIC),
+        "the created topic must appear in the listing, got: {listed}"
+    );
+
+    let deleted = kafka_topics(gateway, &["--delete", "--topic", NEW_TOPIC])
+        .expect_ran("kafka-topics.sh --delete");
+    assert!(
+        !deleted.to_lowercase().contains("error"),
+        "kafka-topics.sh --delete must succeed, got: {deleted}"
+    );
+
+    let listed_after =
+        kafka_topics(gateway, &["--list"]).expect_ran("kafka-topics.sh --list 
after delete");
+    assert!(
+        !listed_after.lines().any(|line| line.trim() == NEW_TOPIC),
+        "the deleted topic must no longer appear in the listing, got: 
{listed_after}"
+    );
+    assert!(
+        listed_after.lines().any(|line| line.trim() == TOPIC),
+        "deleting one topic must not take the shared default stream, and 
`{TOPIC}`, with it, got: \
+         {listed_after}"
+    );
+}
+
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn 
given_records_produced_by_kcat_when_kcat_consumes_should_read_them_back_in_order()
 {
     let Some((_server, gateway)) = bridged_stack().await else {
diff --git a/gateways/kafka/tests/sasl_tests.rs 
b/gateways/kafka/tests/sasl_tests.rs
index 2f6d5e3f8..476d0b84f 100644
--- a/gateways/kafka/tests/sasl_tests.rs
+++ b/gateways/kafka/tests/sasl_tests.rs
@@ -576,9 +576,9 @@ async fn 
given_sasl_disabled_when_a_client_connects_should_serve_without_authent
 
     let advertised = send(&mut stream, API_KEY_API_VERSIONS, 1, 2, &[]).await;
     assert!(
-        !advertised
-            .windows(2)
-            .any(|w| i16::from_be_bytes([w[0], w[1]]) == 
API_KEY_SASL_HANDSHAKE),
+        !parse_api_versions(&advertised)
+            .iter()
+            .any(|&(key, _, _)| key == API_KEY_SASL_HANDSHAKE),
         "the SASL keys must not be advertised while the feature is off"
     );
 }
diff --git a/gateways/kafka/tests/version_firewall_tests.rs 
b/gateways/kafka/tests/version_firewall_tests.rs
index 31240a70a..c3b7620f3 100644
--- a/gateways/kafka/tests/version_firewall_tests.rs
+++ b/gateways/kafka/tests/version_firewall_tests.rs
@@ -37,12 +37,12 @@ use tokio::io::AsyncWriteExt;
 use tokio::net::TcpStream;
 
 use iggy_gateway_kafka::protocol::api::{
-    API_KEY_ALTER_CONFIGS, API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, 
API_KEY_DESCRIBE_CONFIGS,
-    API_KEY_FETCH, API_KEY_FIND_COORDINATOR, API_KEY_HEARTBEAT, 
API_KEY_INIT_PRODUCER_ID,
-    API_KEY_JOIN_GROUP, API_KEY_LEAVE_GROUP, API_KEY_LIST_OFFSETS, 
API_KEY_METADATA,
-    API_KEY_OFFSET_COMMIT, API_KEY_OFFSET_FETCH, API_KEY_PRODUCE, 
API_KEY_SYNC_GROUP,
-    ERROR_INVALID_REQUEST, ERROR_NONE, ERROR_UNSUPPORTED_VERSION, 
advertised_min_version,
-    handle_request, is_supported_version, supported_api_ranges,
+    API_KEY_ALTER_CONFIGS, API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, 
API_KEY_DELETE_TOPICS,
+    API_KEY_DESCRIBE_CONFIGS, API_KEY_FETCH, API_KEY_FIND_COORDINATOR, 
API_KEY_HEARTBEAT,
+    API_KEY_INIT_PRODUCER_ID, API_KEY_JOIN_GROUP, API_KEY_LEAVE_GROUP, 
API_KEY_LIST_OFFSETS,
+    API_KEY_METADATA, API_KEY_OFFSET_COMMIT, API_KEY_OFFSET_FETCH, 
API_KEY_PRODUCE,
+    API_KEY_SYNC_GROUP, ERROR_INVALID_REQUEST, ERROR_NONE, 
ERROR_UNSUPPORTED_VERSION,
+    advertised_min_version, handle_request, is_supported_version, 
supported_api_ranges,
 };
 
 use codec::Decoder;
@@ -57,17 +57,17 @@ use tcp::{
 use wire::{
     JoinGroupParams, OUT_OF_SCOPE_API_KEYS, SyncGroupParams, 
build_alter_configs_empty_request,
     build_api_versions_flexible_request, build_create_topics_empty_request,
-    build_describe_configs_empty_request, build_fetch_empty_topics_request,
-    build_find_coordinator_request, build_heartbeat_request, 
build_init_producer_id_request,
-    build_join_group_request, build_leave_group_request, 
build_list_offsets_request,
-    build_metadata_all_topics_flexible, build_metadata_all_topics_legacy,
-    build_metadata_flexible_request_v10, build_offset_commit_request, 
build_offset_fetch_request,
-    build_sync_group_request,
+    build_delete_topics_empty_request, build_describe_configs_empty_request,
+    build_fetch_empty_topics_request, build_find_coordinator_request, 
build_heartbeat_request,
+    build_init_producer_id_request, build_join_group_request, 
build_leave_group_request,
+    build_list_offsets_request, build_metadata_all_topics_flexible,
+    build_metadata_all_topics_legacy, build_metadata_flexible_request_v10,
+    build_offset_commit_request, build_offset_fetch_request, 
build_sync_group_request,
 };
 
 #[test]
-fn supported_ranges_table_has_sixteen_entries() {
-    assert_eq!(supported_api_ranges().len(), 16);
+fn supported_ranges_table_has_seventeen_entries() {
+    assert_eq!(supported_api_ranges().len(), 17);
 }
 
 #[test]
@@ -104,7 +104,10 @@ fn is_supported_version_matches_scope_table() {
 ///
 /// The wire rows are `supported_api_ranges()` sorted by `api_key` 
(`api_versions.rs`).
 /// `SCOPED_API_KEYS` is already in that ascending order, and this test reads 
the response
-/// in that order. `supported_ranges_table_has_sixteen_entries` and
+/// in that order (Produce, Fetch, `ListOffsets`, Metadata, `OffsetCommit`, 
`OffsetFetch`,
+/// `FindCoordinator`, `JoinGroup`, Heartbeat, `LeaveGroup`, `SyncGroup`, 
`ApiVersions`,
+/// `CreateTopics`, `DeleteTopics`, `InitProducerId`, `DescribeConfigs`, 
`AlterConfigs`).
+/// `supported_ranges_table_has_seventeen_entries` and
 /// `is_supported_version_matches_scope_table` pin that both tables cover the 
same keys.
 #[tokio::test]
 async fn apiversions_advertises_exact_supported_ranges_v1() {
@@ -496,6 +499,7 @@ fn request_body_for_scoped_api(api_key: i16, name: &str, 
version: i16) -> Bytes
             .flatten()
             .unwrap_or_else(|| build_list_offsets_request(version, 
"scope-topic", 0)),
         API_KEY_CREATE_TOPICS => build_create_topics_empty_request(version),
+        API_KEY_DELETE_TOPICS => build_delete_topics_empty_request(version),
         API_KEY_FIND_COORDINATOR => build_find_coordinator_request(version, 
&["scope-group"], 0),
         // A member id the coordinator never handed out, so every in-range 
version answers
         // UNKNOWN_MEMBER_ID immediately instead of creating a group and 
parking on its join

Reply via email to