|
1 | 1 | import pytest |
2 | 2 |
|
3 | 3 | import kafka.admin |
4 | | -from kafka.errors import IllegalArgumentError |
| 4 | +from kafka.admin.client import KafkaAdminClient |
| 5 | +from kafka.errors import IllegalArgumentError, KafkaTimeoutError, UnknownTopicOrPartitionError |
5 | 6 |
|
6 | 7 |
|
7 | 8 | def test_config_resource(): |
@@ -92,3 +93,145 @@ def test_new_topic(): |
92 | 93 | assert good_topic.replication_factor == -1 |
93 | 94 | assert good_topic.replica_assignments == {1: [1, 2, 3]} |
94 | 95 | assert good_topic.topic_configs == {'key': 'value'} |
| 96 | + |
| 97 | + |
| 98 | +# --------------------------------------------------------------------------- |
| 99 | +# _topic_not_ready_reason (pure function, no network) |
| 100 | +# --------------------------------------------------------------------------- |
| 101 | + |
| 102 | + |
| 103 | +def _ready_topic(name='foo', num_partitions=1): |
| 104 | + return { |
| 105 | + 'name': name, |
| 106 | + 'error_code': 0, |
| 107 | + 'partitions': [ |
| 108 | + {'error_code': 0, 'partition_index': i, 'leader_id': 0} |
| 109 | + for i in range(num_partitions) |
| 110 | + ], |
| 111 | + } |
| 112 | + |
| 113 | + |
| 114 | +def test_topic_not_ready_reason_missing(): |
| 115 | + assert KafkaAdminClient._topic_not_ready_reason(None) == 'missing from metadata response' |
| 116 | + |
| 117 | + |
| 118 | +def test_topic_not_ready_reason_topic_error(): |
| 119 | + assert KafkaAdminClient._topic_not_ready_reason( |
| 120 | + {'name': 'foo', 'error_code': 3, 'partitions': []} |
| 121 | + ) == 'UnknownTopicOrPartitionError' |
| 122 | + |
| 123 | + |
| 124 | +def test_topic_not_ready_reason_no_partitions(): |
| 125 | + assert KafkaAdminClient._topic_not_ready_reason( |
| 126 | + {'name': 'foo', 'error_code': 0, 'partitions': []} |
| 127 | + ) == 'no partitions reported' |
| 128 | + |
| 129 | + |
| 130 | +def test_topic_not_ready_reason_no_leader(): |
| 131 | + assert KafkaAdminClient._topic_not_ready_reason( |
| 132 | + {'name': 'foo', 'error_code': 0, 'partitions': [ |
| 133 | + {'error_code': 0, 'partition_index': 0, 'leader_id': -1}, |
| 134 | + {'error_code': 0, 'partition_index': 1, 'leader_id': 0}, |
| 135 | + ]} |
| 136 | + ) == 'p0=no leader' |
| 137 | + |
| 138 | + |
| 139 | +def test_topic_not_ready_reason_partition_error(): |
| 140 | + assert KafkaAdminClient._topic_not_ready_reason( |
| 141 | + {'name': 'foo', 'error_code': 0, 'partitions': [ |
| 142 | + {'error_code': 5, 'partition_index': 0, 'leader_id': -1}, |
| 143 | + ]} |
| 144 | + ) == 'p0=LeaderNotAvailableError' |
| 145 | + |
| 146 | + |
| 147 | +def test_topic_not_ready_reason_partial_partition_errors(): |
| 148 | + # Multiple partitions each with their own issue -> all reasons joined. |
| 149 | + reason = KafkaAdminClient._topic_not_ready_reason( |
| 150 | + {'name': 'foo', 'error_code': 0, 'partitions': [ |
| 151 | + {'error_code': 0, 'partition_index': 0, 'leader_id': -1}, |
| 152 | + {'error_code': 5, 'partition_index': 1, 'leader_id': -1}, |
| 153 | + ]} |
| 154 | + ) |
| 155 | + assert 'p0=no leader' in reason |
| 156 | + assert 'p1=LeaderNotAvailableError' in reason |
| 157 | + |
| 158 | + |
| 159 | +def test_topic_not_ready_reason_ready(): |
| 160 | + assert KafkaAdminClient._topic_not_ready_reason(_ready_topic()) is None |
| 161 | + |
| 162 | + |
| 163 | +# --------------------------------------------------------------------------- |
| 164 | +# wait_for_topics (mocks describe_topics; does not hit the network) |
| 165 | +# --------------------------------------------------------------------------- |
| 166 | + |
| 167 | + |
| 168 | +def _bare_admin(): |
| 169 | + """Return a KafkaAdminClient instance without running __init__ (which |
| 170 | + would try to bootstrap a real broker). All attributes needed by the |
| 171 | + method under test are provided by the test. |
| 172 | + """ |
| 173 | + return object.__new__(KafkaAdminClient) |
| 174 | + |
| 175 | + |
| 176 | +def test_wait_for_topics_empty_list_returns_immediately(): |
| 177 | + admin = _bare_admin() |
| 178 | + # No describe_topics monkey-patch: if it were called the test would |
| 179 | + # crash with AttributeError, proving the empty-list fast path. |
| 180 | + admin.wait_for_topics([]) |
| 181 | + |
| 182 | + |
| 183 | +def test_wait_for_topics_ready_on_first_call(monkeypatch): |
| 184 | + admin = _bare_admin() |
| 185 | + calls = [] |
| 186 | + def fake_describe_topics(topics): |
| 187 | + calls.append(topics) |
| 188 | + return [_ready_topic(name=t, num_partitions=2) for t in topics] |
| 189 | + monkeypatch.setattr(admin, 'describe_topics', fake_describe_topics) |
| 190 | + admin.wait_for_topics(['foo', 'bar'], timeout_ms=1000) |
| 191 | + assert len(calls) == 1 |
| 192 | + assert set(calls[0]) == {'foo', 'bar'} |
| 193 | + |
| 194 | + |
| 195 | +def test_wait_for_topics_becomes_ready_after_retry(monkeypatch): |
| 196 | + admin = _bare_admin() |
| 197 | + responses = [ |
| 198 | + # First call: topic missing |
| 199 | + [], |
| 200 | + # Second call: topic exists but no leader yet |
| 201 | + [{'name': 'foo', 'error_code': 0, 'partitions': [ |
| 202 | + {'error_code': 0, 'partition_index': 0, 'leader_id': -1}]}], |
| 203 | + # Third call: ready |
| 204 | + [_ready_topic(name='foo')], |
| 205 | + ] |
| 206 | + def fake_describe_topics(topics): |
| 207 | + return responses.pop(0) |
| 208 | + monkeypatch.setattr(admin, 'describe_topics', fake_describe_topics) |
| 209 | + admin.wait_for_topics(['foo'], timeout_ms=5000) |
| 210 | + assert responses == [] # all three calls consumed |
| 211 | + |
| 212 | + |
| 213 | +def test_wait_for_topics_timeout(monkeypatch): |
| 214 | + admin = _bare_admin() |
| 215 | + def fake_describe_topics(topics): |
| 216 | + return [{'name': 'foo', 'error_code': 3, 'partitions': []}] |
| 217 | + monkeypatch.setattr(admin, 'describe_topics', fake_describe_topics) |
| 218 | + with pytest.raises(KafkaTimeoutError) as exc_info: |
| 219 | + admin.wait_for_topics(['foo'], timeout_ms=200) |
| 220 | + assert 'foo' in str(exc_info.value) |
| 221 | + assert 'UnknownTopicOrPartitionError' in str(exc_info.value) |
| 222 | + |
| 223 | + |
| 224 | +def test_wait_for_topics_describe_exception_keeps_retrying(monkeypatch): |
| 225 | + """A transient exception from describe_topics should be logged and |
| 226 | + retried, not propagated - otherwise a flaky broker could turn a |
| 227 | + recoverable wait into a hard failure.""" |
| 228 | + admin = _bare_admin() |
| 229 | + state = {'calls': 0} |
| 230 | + def fake_describe_topics(topics): |
| 231 | + state['calls'] += 1 |
| 232 | + if state['calls'] == 1: |
| 233 | + raise RuntimeError('transient') |
| 234 | + return [_ready_topic(name=t) for t in topics] |
| 235 | + monkeypatch.setattr(admin, 'describe_topics', fake_describe_topics) |
| 236 | + admin.wait_for_topics(['foo'], timeout_ms=5000) |
| 237 | + assert state['calls'] == 2 |
0 commit comments