Skip to content

Commit a3b2ef6

Browse files
Address review: bound bisect depth, respect shutdown, simplify control flow
- Add _MAX_BISECTS=5 to cap recursive splitting depth - Combine 413 guard with len>1 and remaining_bisects>0 check so single-item 413 falls through to the existing non-retryable path - Check self._shutdown alongside deadline in the 413 handler - Add tests for max bisect depth exhaustion and shutdown during 413
1 parent 94374ca commit a3b2ef6

File tree

4 files changed

+173
-38
lines changed

4 files changed

+173
-38
lines changed

exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
DEFAULT_LOGS_EXPORT_PATH = "v1/logs"
7171
DEFAULT_TIMEOUT = 10 # in seconds
7272
_MAX_RETRYS = 6
73+
_MAX_BISECTS = 5
7374

7475

7576
class OTLPLogExporter(LogRecordExporter):
@@ -185,10 +186,13 @@ def export(
185186
return LogRecordExportResult.FAILURE
186187

187188
deadline_sec = time() + self._timeout
188-
return self._export_batch(batch, deadline_sec)
189+
return self._export_batch(batch, deadline_sec, _MAX_BISECTS)
189190

190191
def _export_batch(
191-
self, batch: Sequence[ReadableLogRecord], deadline_sec: float
192+
self,
193+
batch: Sequence[ReadableLogRecord],
194+
deadline_sec: float,
195+
remaining_bisects: int,
192196
) -> LogRecordExportResult:
193197
serialized_data = encode_logs(batch).SerializeToString()
194198

@@ -208,17 +212,17 @@ def _export_batch(
208212
retryable = _is_retryable(resp)
209213
status_code = resp.status_code
210214

211-
if _is_payload_too_large(resp):
212-
# 413 handling always returns here; will not fall through
213-
# to the 'if not retryable' check below.
214-
if len(batch) <= 1:
215+
if (
216+
_is_payload_too_large(resp)
217+
and len(batch) > 1
218+
and remaining_bisects > 0
219+
):
220+
if time() >= deadline_sec or self._shutdown:
215221
_logger.error(
216-
"Single log record exceeds backend payload size limit, dropping log record"
217-
)
218-
return LogRecordExportResult.FAILURE
219-
if time() >= deadline_sec:
220-
_logger.error(
221-
"Payload too large but deadline expired, dropping %d log records",
222+
"Payload too large but %s, dropping %d log records",
223+
"shutdown in progress"
224+
if self._shutdown
225+
else "deadline expired",
222226
len(batch),
223227
)
224228
return LogRecordExportResult.FAILURE
@@ -227,13 +231,18 @@ def _export_batch(
227231
"Payload too large (%d log records), splitting into two batches",
228232
len(batch),
229233
)
230-
first = self._export_batch(list(batch[:mid]), deadline_sec)
234+
first = self._export_batch(
235+
list(batch[:mid]),
236+
deadline_sec,
237+
remaining_bisects - 1,
238+
)
231239
if first != LogRecordExportResult.SUCCESS:
232240
return LogRecordExportResult.FAILURE
233-
second = self._export_batch(
234-
list(batch[mid:]), deadline_sec
241+
return self._export_batch(
242+
list(batch[mid:]),
243+
deadline_sec,
244+
remaining_bisects - 1,
235245
)
236-
return second
237246

238247
if not retryable:
239248
_logger.error(

exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
DEFAULT_TRACES_EXPORT_PATH = "v1/traces"
6767
DEFAULT_TIMEOUT = 10 # in seconds
6868
_MAX_RETRYS = 6
69+
_MAX_BISECTS = 5
6970

7071

7172
class OTLPSpanExporter(SpanExporter):
@@ -178,10 +179,13 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
178179
return SpanExportResult.FAILURE
179180

180181
deadline_sec = time() + self._timeout
181-
return self._export_batch(spans, deadline_sec)
182+
return self._export_batch(spans, deadline_sec, _MAX_BISECTS)
182183

183184
def _export_batch(
184-
self, spans: Sequence[ReadableSpan], deadline_sec: float
185+
self,
186+
spans: Sequence[ReadableSpan],
187+
deadline_sec: float,
188+
remaining_bisects: int,
185189
) -> SpanExportResult:
186190
serialized_data = encode_spans(spans).SerializePartialToString()
187191

@@ -201,17 +205,17 @@ def _export_batch(
201205
retryable = _is_retryable(resp)
202206
status_code = resp.status_code
203207

204-
if _is_payload_too_large(resp):
205-
# 413 handling always returns here; will not fall through
206-
# to the 'if not retryable' check below.
207-
if len(spans) <= 1:
208+
if (
209+
_is_payload_too_large(resp)
210+
and len(spans) > 1
211+
and remaining_bisects > 0
212+
):
213+
if time() >= deadline_sec or self._shutdown:
208214
_logger.error(
209-
"Single span exceeds backend payload size limit, dropping span"
210-
)
211-
return SpanExportResult.FAILURE
212-
if time() >= deadline_sec:
213-
_logger.error(
214-
"Payload too large but deadline expired, dropping %d spans",
215+
"Payload too large but %s, dropping %d spans",
216+
"shutdown in progress"
217+
if self._shutdown
218+
else "deadline expired",
215219
len(spans),
216220
)
217221
return SpanExportResult.FAILURE
@@ -220,13 +224,18 @@ def _export_batch(
220224
"Payload too large (%d spans), splitting into two batches",
221225
len(spans),
222226
)
223-
first = self._export_batch(list(spans[:mid]), deadline_sec)
227+
first = self._export_batch(
228+
list(spans[:mid]),
229+
deadline_sec,
230+
remaining_bisects - 1,
231+
)
224232
if first != SpanExportResult.SUCCESS:
225233
return SpanExportResult.FAILURE
226-
second = self._export_batch(
227-
list(spans[mid:]), deadline_sec
234+
return self._export_batch(
235+
list(spans[mid:]),
236+
deadline_sec,
237+
remaining_bisects - 1,
228238
)
229-
return second
230239

231240
if not retryable:
232241
_logger.error(

exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ def test_413_splits_batch_and_succeeds(self, mock_post):
594594

595595
@patch.object(Session, "post")
596596
def test_413_single_log_returns_failure(self, mock_post):
597-
"""When a single log record is too large, the exporter should return FAILURE."""
597+
"""When a single log record is too large, the exporter should return FAILURE via the non-retryable path."""
598598
exporter = OTLPLogExporter(timeout=10)
599599

600600
resp_413 = Response()
@@ -612,8 +612,7 @@ def test_413_single_log_returns_failure(self, mock_post):
612612
self.assertEqual(mock_post.call_count, 1)
613613
self.assertTrue(
614614
any(
615-
"Single log record exceeds backend payload size limit"
616-
in record.message
615+
"Failed to export logs batch code: 413" in record.message
617616
for record in warning.records
618617
)
619618
)
@@ -695,3 +694,45 @@ def test_413_deadline_expired_returns_failure(self, mock_post, mock_time):
695694
for record in warning.records
696695
)
697696
)
697+
698+
@patch.object(Session, "post")
699+
def test_413_max_bisects_exceeded_returns_failure(self, mock_post):
700+
"""When max bisect depth is exhausted, the exporter should stop splitting and return FAILURE."""
701+
exporter = OTLPLogExporter(timeout=10)
702+
703+
resp_413 = Response()
704+
resp_413.status_code = 413
705+
resp_413.reason = "Request Entity Too Large"
706+
707+
# Always return 413 — should stop after _MAX_BISECTS depth
708+
mock_post.return_value = resp_413
709+
710+
log_data = self._get_sdk_log_data()
711+
712+
with self.assertLogs(level=WARNING):
713+
result = exporter.export(log_data)
714+
715+
self.assertEqual(result, LogRecordExportResult.FAILURE)
716+
# Should not recurse indefinitely — bounded by _MAX_BISECTS
717+
718+
@patch.object(Session, "post")
719+
def test_413_shutdown_returns_failure(self, mock_post):
720+
"""When a 413 is received but shutdown is in progress, return FAILURE without splitting."""
721+
exporter = OTLPLogExporter(timeout=10)
722+
723+
resp_413 = Response()
724+
resp_413.status_code = 413
725+
resp_413.reason = "Request Entity Too Large"
726+
727+
mock_post.return_value = resp_413
728+
729+
log_data = self._get_sdk_log_data()
730+
731+
# Set shutdown before export
732+
exporter._shutdown = True
733+
734+
with self.assertLogs(level=WARNING):
735+
result = exporter.export(log_data)
736+
737+
# export() itself returns FAILURE early on shutdown
738+
self.assertEqual(result, LogRecordExportResult.FAILURE)

exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ def test_413_splits_batch_and_succeeds(self, mock_post):
430430

431431
@patch.object(Session, "post")
432432
def test_413_single_span_returns_failure(self, mock_post):
433-
"""When a single span is too large, the exporter should return FAILURE."""
433+
"""When a single span is too large, the exporter should return FAILURE via the non-retryable path."""
434434
exporter = OTLPSpanExporter(timeout=10)
435435

436436
resp_413 = Response()
@@ -446,8 +446,7 @@ def test_413_single_span_returns_failure(self, mock_post):
446446
self.assertEqual(mock_post.call_count, 1)
447447
self.assertTrue(
448448
any(
449-
"Single span exceeds backend payload size limit"
450-
in record.message
449+
"Failed to export span batch code: 413" in record.message
451450
for record in warning.records
452451
)
453452
)
@@ -582,3 +581,80 @@ def test_413_deadline_expired_returns_failure(self, mock_post, mock_time):
582581
for record in warning.records
583582
)
584583
)
584+
585+
@patch.object(Session, "post")
586+
def test_413_max_bisects_exceeded_returns_failure(self, mock_post):
587+
"""When max bisect depth is exhausted, the exporter should stop splitting and return FAILURE."""
588+
exporter = OTLPSpanExporter(timeout=10)
589+
590+
resp_413 = Response()
591+
resp_413.status_code = 413
592+
resp_413.reason = "Request Entity Too Large"
593+
594+
# Always return 413 — should stop after _MAX_BISECTS depth
595+
mock_post.return_value = resp_413
596+
597+
# Create a batch large enough to bisect _MAX_BISECTS times (2^5 = 32 items)
598+
spans = []
599+
for idx in range(32):
600+
spans.append(
601+
_Span(
602+
f"span{idx}",
603+
context=Mock(
604+
**{
605+
"trace_state": {},
606+
"span_id": 10217189687419569865 + idx,
607+
"trace_id": 67545097771067222548457157018666467027,
608+
}
609+
),
610+
)
611+
)
612+
613+
with self.assertLogs(level=WARNING):
614+
result = exporter.export(spans)
615+
616+
self.assertEqual(result, SpanExportResult.FAILURE)
617+
# Should not recurse indefinitely — bounded by _MAX_BISECTS
618+
# At depth 5, batch is 1 item → falls through to non-retryable path
619+
self.assertLessEqual(mock_post.call_count, 63)
620+
621+
@patch.object(Session, "post")
622+
def test_413_shutdown_returns_failure(self, mock_post):
623+
"""When a 413 is received but shutdown is in progress, return FAILURE without splitting."""
624+
exporter = OTLPSpanExporter(timeout=10)
625+
626+
resp_413 = Response()
627+
resp_413.status_code = 413
628+
resp_413.reason = "Request Entity Too Large"
629+
630+
mock_post.return_value = resp_413
631+
632+
span1 = _Span(
633+
"span1",
634+
context=Mock(
635+
**{
636+
"trace_state": {},
637+
"span_id": 10217189687419569865,
638+
"trace_id": 67545097771067222548457157018666467027,
639+
}
640+
),
641+
)
642+
span2 = _Span(
643+
"span2",
644+
context=Mock(
645+
**{
646+
"trace_state": {},
647+
"span_id": 10217189687419569866,
648+
"trace_id": 67545097771067222548457157018666467027,
649+
}
650+
),
651+
)
652+
653+
# Set shutdown before export
654+
exporter._shutdown = True
655+
656+
with self.assertLogs(level=WARNING):
657+
result = exporter.export([span1, span2])
658+
659+
# export() itself returns FAILURE early on shutdown
660+
self.assertEqual(result, SpanExportResult.FAILURE)

0 commit comments

Comments
 (0)