From 217a84bf08c63828e7eb387338ab492913b2a145 Mon Sep 17 00:00:00 2001 From: Srija Vuppala Date: Wed, 29 Jul 2026 12:53:47 -0400 Subject: [PATCH 1/3] Retry OTLP/HTTP exports on HTTP 429 (Too Many Requests) (#3122) The shared `_is_retryable` helper used by the OTLP/HTTP trace, metric and log exporters retried on 408 and 5xx responses, but not on 429 (Too Many Requests). The OTLP specification lists 429 as a retryable status code, so a throttled export was being dropped instead of retried with backoff. Add 429 to `_is_retryable` and a direct unit test covering the retryable and non-retryable status codes. This is intentionally limited to the missing, spec-required 429 case and does not change the existing 408/5xx handling. --- .../otlp/proto/http/_common/__init__.py | 6 ++++ .../tests/test_common.py | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 exporter/opentelemetry-exporter-otlp-proto-http/tests/test_common.py diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py index 46db16dd86..63d22e8f38 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py @@ -26,6 +26,12 @@ class RequestPayloadTooLargeError(Exception): def _is_retryable(resp: requests.Response) -> bool: if resp.status_code == 408: return True + # 429 (Too Many Requests) is listed as a retryable status code by the OTLP + # specification, so a throttled export should be retried with backoff rather + # than dropped. See + # https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md#failures-1 + if resp.status_code == 429: + return True if resp.status_code >= 500 and resp.status_code <= 599: return True return False diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_common.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_common.py new file mode 100644 index 0000000000..aa6b5d1760 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_common.py @@ -0,0 +1,28 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +import requests + +from opentelemetry.exporter.otlp.proto.http._common import _is_retryable + + +class TestIsRetryable(unittest.TestCase): + @staticmethod + def _response(status_code: int) -> requests.Response: + resp = requests.Response() + resp.status_code = status_code + return resp + + def test_retryable_status_codes(self): + # 408 (Request Timeout), 429 (Too Many Requests) and any 5xx are + # retryable per the OTLP specification. + for status_code in (408, 429, 500, 502, 503, 504, 599): + with self.subTest(status_code=status_code): + self.assertTrue(_is_retryable(self._response(status_code))) + + def test_non_retryable_status_codes(self): + for status_code in (200, 400, 401, 403, 404, 409): + with self.subTest(status_code=status_code): + self.assertFalse(_is_retryable(self._response(status_code))) From 44781654d75300f3601f26d94f7155d737be2b0d Mon Sep 17 00:00:00 2001 From: Srija Vuppala Date: Wed, 29 Jul 2026 12:53:47 -0400 Subject: [PATCH 2/3] Add changelog fragment for #5473 --- .changelog/5473.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5473.fixed diff --git a/.changelog/5473.fixed b/.changelog/5473.fixed new file mode 100644 index 0000000000..82e490fce6 --- /dev/null +++ b/.changelog/5473.fixed @@ -0,0 +1 @@ +`opentelemetry-exporter-otlp-proto-http`: retry exports on HTTP 429 (Too Many Requests) responses From 66ebf7c95bb80b47f75ba9b6638a56f8de372e3f Mon Sep 17 00:00:00 2001 From: Srija Vuppala Date: Thu, 30 Jul 2026 14:27:08 -0400 Subject: [PATCH 3/3] Add per-exporter tests that HTTP 429 is retried (#3122) Add test_retryable_status_code_429 to the trace, metric and log OTLP/HTTP exporter test suites, asserting that a 429 (Too Many Requests) response is retried (more than one POST attempt) rather than dropped. This complements the shared _is_retryable unit test by covering the retry behaviour end-to-end for each signal. --- .../metrics/test_otlp_metrics_exporter.py | 18 ++++++++++++++++++ .../tests/test_proto_log_exporter.py | 18 ++++++++++++++++++ .../tests/test_proto_span_exporter.py | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py index 9ead610069..811f7eab0a 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py @@ -1427,6 +1427,24 @@ def test_preferred_aggregation_override(self): exporter._preferred_aggregation[Histogram], histogram_aggregation ) + @patch.object(Session, "post") + def test_retryable_status_code_429(self, mock_post): + # HTTP 429 (Too Many Requests) is retryable per the OTLP spec, so the + # exporter should back off and retry instead of dropping the batch. + exporter = OTLPMetricExporter(timeout=1.5) + + resp = Response() + resp.status_code = 429 + resp.reason = "Too Many Requests" + mock_post.return_value = resp + with self.assertLogs(level=WARNING): + self.assertEqual( + exporter.export(self.metrics["sum_int"]), + MetricExportResult.FAILURE, + ) + # More than one call proves the 429 response was retried. + self.assertGreater(mock_post.call_count, 1) + @patch.dict( "os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: "true"} ) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py index 3663b0eb9b..81d1807b30 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py @@ -457,6 +457,24 @@ def test_2xx_status_code(self, mock_otlp_metric_exporter): LogRecordExportResult.SUCCESS, ) + @patch.object(Session, "post") + def test_retryable_status_code_429(self, mock_post): + # HTTP 429 (Too Many Requests) is retryable per the OTLP spec, so the + # exporter should back off and retry instead of dropping the batch. + exporter = OTLPLogExporter(timeout=1.5) + + resp = Response() + resp.status_code = 429 + resp.reason = "Too Many Requests" + mock_post.return_value = resp + with self.assertLogs(level=WARNING): + self.assertEqual( + exporter.export(self._get_sdk_log_data()), + LogRecordExportResult.FAILURE, + ) + # More than one call proves the 429 response was retried. + self.assertGreater(mock_post.call_count, 1) + @patch.dict( "os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: " true "} ) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py index fa8b5fc1f4..e07339e9f3 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py @@ -313,6 +313,24 @@ def test_exporter_metrics_disabled_by_default(self, _mock_export): self.assertIsNone(self.metric_reader.get_metrics_data()) + @patch.object(Session, "post") + def test_retryable_status_code_429(self, mock_post): + # HTTP 429 (Too Many Requests) is retryable per the OTLP spec, so the + # exporter should back off and retry instead of dropping the batch. + exporter = OTLPSpanExporter(timeout=1.5) + + resp = Response() + resp.status_code = 429 + resp.reason = "Too Many Requests" + mock_post.return_value = resp + with self.assertLogs(level=WARNING): + self.assertEqual( + exporter.export([BASIC_SPAN]), + SpanExportResult.FAILURE, + ) + # More than one call proves the 429 response was retried. + self.assertGreater(mock_post.call_count, 1) + @patch.dict( "os.environ", {OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED: " true "} )