Skip to content

Commit 47b2307

Browse files
authored
fix(consumer): stop rebuilding client on transient get_records errors (#73) (#93)
* fix: stop rebuilding consumer client on transient fetch errors * test: dedupe metrics sleep patching
1 parent 35531c3 commit 47b2307

2 files changed

Lines changed: 50 additions & 47 deletions

File tree

kinesis/consumer.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -518,8 +518,8 @@ async def get_records(self, shard):
518518
1,
519519
{"stream_name": self.stream_name, "shard_id": shard["ShardId"], "error_type": "connection"},
520520
)
521-
log.warning("Connection error {}. rebuilding client..".format(e))
522-
await self.get_conn()
521+
log.warning("Connection error {}. sleeping..".format(e))
522+
await asyncio.sleep(3)
523523
except (TimeoutError, ReadTimeoutError) as e:
524524
self.metrics.increment(
525525
MetricType.CONSUMER_ERRORS,
@@ -571,8 +571,9 @@ async def get_records(self, shard):
571571
shard.pop("ShardIterator", None)
572572

573573
elif code == "InternalFailure":
574-
log.warning("Received InternalFailure from Kinesis, rebuilding connection.. ")
575-
await self.get_conn()
574+
message = e.response.get("Error", {}).get("Message", str(e))
575+
log.warning("Received InternalFailure from Kinesis: {}. sleeping..".format(message))
576+
await asyncio.sleep(3)
576577

577578
else:
578579
log.warning("ClientError {}. sleeping..".format(code))

tests/test_metrics_unit.py

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@
2828
from kinesis.utils import Throttler
2929

3030

31+
@pytest.fixture
32+
def fast_asyncio_sleep(monkeypatch):
33+
"""Replace asyncio.sleep with a zero-delay awaitable for retry-path tests."""
34+
orig_sleep = asyncio.sleep
35+
36+
async def fast_sleep(_):
37+
await orig_sleep(0)
38+
39+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
40+
return fast_sleep
41+
42+
3143
class TestNoOpMetricsCollector:
3244
def test_all_operations_are_noop(self):
3345
"""Test that NoOpMetricsCollector does nothing."""
@@ -501,7 +513,7 @@ def _shard(shard_id="shard-0"):
501513
}
502514

503515
@pytest.mark.asyncio
504-
async def test_connection_error_emits_connection_label(self, mock_consumer):
516+
async def test_connection_error_emits_connection_label(self, mock_consumer, fast_asyncio_sleep):
505517
collector = InMemoryMetricsCollector()
506518
consumer = mock_consumer(metrics_collector=collector)
507519
consumer.client = MagicMock()
@@ -512,9 +524,10 @@ async def test_connection_error_emits_connection_label(self, mock_consumer):
512524

513525
key = f"consumer_errors_total{{error_type=connection,{_shard_labels('shard-0')}}}"
514526
assert collector.counters[key] == 1
527+
consumer.get_conn.assert_not_awaited()
515528

516529
@pytest.mark.asyncio
517-
async def test_botocore_connection_closed_emits_connection_label(self, mock_consumer):
530+
async def test_botocore_connection_closed_emits_connection_label(self, mock_consumer, fast_asyncio_sleep):
518531
"""Real AWS commonly raises botocore ConnectionClosedError when an idle
519532
keep-alive connection is dropped mid-response. Prior to #73 fix this fell
520533
through to the catch-all and got mislabeled as error_type=unknown."""
@@ -530,12 +543,12 @@ async def test_botocore_connection_closed_emits_connection_label(self, mock_cons
530543

531544
key = f"consumer_errors_total{{error_type=connection,{_shard_labels('shard-0')}}}"
532545
assert collector.counters[key] == 1
533-
# Must rebuild client, not just sleep — same remediation as ClientConnectionError
534-
consumer.get_conn.assert_awaited_once()
546+
consumer.get_conn.assert_not_awaited()
535547

536548
@pytest.mark.asyncio
537-
async def test_botocore_endpoint_connection_error_emits_connection_label(self, mock_consumer):
538-
"""botocore.EndpointConnectionError (DNS/TCP failure before request is sent)."""
549+
async def test_botocore_endpoint_connection_error_emits_connection_label(self, mock_consumer, fast_asyncio_sleep):
550+
"""botocore.EndpointConnectionError (DNS/TCP failure before request is sent)
551+
should be counted as a connection error and return without rebuilding."""
539552
collector = InMemoryMetricsCollector()
540553
consumer = mock_consumer(metrics_collector=collector)
541554
consumer.client = MagicMock()
@@ -548,10 +561,10 @@ async def test_botocore_endpoint_connection_error_emits_connection_label(self, m
548561

549562
key = f"consumer_errors_total{{error_type=connection,{_shard_labels('shard-0')}}}"
550563
assert collector.counters[key] == 1
551-
consumer.get_conn.assert_awaited_once()
564+
consumer.get_conn.assert_not_awaited()
552565

553566
@pytest.mark.asyncio
554-
async def test_botocore_read_timeout_emits_timeout_label(self, mock_consumer):
567+
async def test_botocore_read_timeout_emits_timeout_label(self, mock_consumer, fast_asyncio_sleep):
555568
"""botocore.ReadTimeoutError lands in the timeout bucket, not unknown."""
556569
collector = InMemoryMetricsCollector()
557570
consumer = mock_consumer(metrics_collector=collector)
@@ -560,40 +573,25 @@ async def test_botocore_read_timeout_emits_timeout_label(self, mock_consumer):
560573
side_effect=ReadTimeoutError(endpoint_url="https://kinesis.us-east-1.amazonaws.com/")
561574
)
562575

563-
orig_sleep = asyncio.sleep
564-
565-
async def fast_sleep(_):
566-
await orig_sleep(0)
567-
568-
with pytest.MonkeyPatch().context() as mp:
569-
mp.setattr(asyncio, "sleep", fast_sleep)
570-
await consumer.get_records(self._shard())
576+
await consumer.get_records(self._shard())
571577

572578
key = f"consumer_errors_total{{error_type=timeout,{_shard_labels('shard-0')}}}"
573579
assert collector.counters[key] == 1
574580

575581
@pytest.mark.asyncio
576-
async def test_timeout_emits_timeout_label(self, mock_consumer):
582+
async def test_timeout_emits_timeout_label(self, mock_consumer, fast_asyncio_sleep):
577583
collector = InMemoryMetricsCollector()
578584
consumer = mock_consumer(metrics_collector=collector)
579585
consumer.client = MagicMock()
580586
consumer.client.get_records = AsyncMock(side_effect=asyncio.TimeoutError())
581587

582-
# Monkeypatch asyncio.sleep inside the except block to avoid 3s wait.
583-
orig_sleep = asyncio.sleep
584-
585-
async def fast_sleep(_):
586-
await orig_sleep(0)
587-
588-
with pytest.MonkeyPatch().context() as mp:
589-
mp.setattr(asyncio, "sleep", fast_sleep)
590-
await consumer.get_records(self._shard())
588+
await consumer.get_records(self._shard())
591589

592590
key = f"consumer_errors_total{{error_type=timeout,{_shard_labels('shard-0')}}}"
593591
assert collector.counters[key] == 1
594592

595593
@pytest.mark.asyncio
596-
async def test_client_error_emits_raw_code_label(self, mock_consumer):
594+
async def test_client_error_emits_raw_code_label(self, mock_consumer, fast_asyncio_sleep):
597595
collector = InMemoryMetricsCollector()
598596
consumer = mock_consumer(metrics_collector=collector)
599597
err = ClientError(
@@ -603,35 +601,39 @@ async def test_client_error_emits_raw_code_label(self, mock_consumer):
603601
consumer.client = MagicMock()
604602
consumer.client.get_records = AsyncMock(side_effect=err)
605603

606-
orig_sleep = asyncio.sleep
607-
608-
async def fast_sleep(_):
609-
await orig_sleep(0)
610-
611-
with pytest.MonkeyPatch().context() as mp:
612-
mp.setattr(asyncio, "sleep", fast_sleep)
613-
await consumer.get_records(self._shard())
604+
await consumer.get_records(self._shard())
614605

615606
key = (
616607
"consumer_errors_total{" f"error_type=ProvisionedThroughputExceededException,{_shard_labels('shard-0')}" "}"
617608
)
618609
assert collector.counters[key] == 1
619610

620611
@pytest.mark.asyncio
621-
async def test_unknown_exception_emits_unknown_label(self, mock_consumer):
612+
async def test_internal_failure_no_longer_rebuilds(self, mock_consumer, fast_asyncio_sleep):
622613
collector = InMemoryMetricsCollector()
623614
consumer = mock_consumer(metrics_collector=collector)
615+
err = ClientError(
616+
{"Error": {"Code": "InternalFailure", "Message": "internal failure"}},
617+
"GetRecords",
618+
)
624619
consumer.client = MagicMock()
625-
consumer.client.get_records = AsyncMock(side_effect=RuntimeError("surprise"))
620+
consumer.client.get_records = AsyncMock(side_effect=err)
621+
consumer.get_conn = AsyncMock()
622+
623+
await consumer.get_records(self._shard())
626624

627-
orig_sleep = asyncio.sleep
625+
key = f"consumer_errors_total{{error_type=InternalFailure,{_shard_labels('shard-0')}}}"
626+
assert collector.counters[key] == 1
627+
consumer.get_conn.assert_not_awaited()
628628

629-
async def fast_sleep(_):
630-
await orig_sleep(0)
629+
@pytest.mark.asyncio
630+
async def test_unknown_exception_emits_unknown_label(self, mock_consumer, fast_asyncio_sleep):
631+
collector = InMemoryMetricsCollector()
632+
consumer = mock_consumer(metrics_collector=collector)
633+
consumer.client = MagicMock()
634+
consumer.client.get_records = AsyncMock(side_effect=RuntimeError("surprise"))
631635

632-
with pytest.MonkeyPatch().context() as mp:
633-
mp.setattr(asyncio, "sleep", fast_sleep)
634-
await consumer.get_records(self._shard())
636+
await consumer.get_records(self._shard())
635637

636638
key = f"consumer_errors_total{{error_type=unknown,{_shard_labels('shard-0')}}}"
637639
assert collector.counters[key] == 1

0 commit comments

Comments
 (0)