From 779849f369a841a0314c9b22bf99e2ae3d6f93c6 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 21 Oct 2026 20:43:59 -0400 Subject: [PATCH 1/3] opentelemetry-configuration: treat timeout of 0 as infinite --- .../exporter/otlp/common/http.py | 9 +++-- .../tests/test_http_client.py | 33 +++++++++++++++++++ .../exporter/otlp/proto/grpc/exporter.py | 6 ++-- .../tests/test_otlp_exporter_mixin.py | 9 +++++ .../opentelemetry/configuration/_common.py | 16 +++++++++ .../configuration/_logger_provider.py | 5 +-- .../configuration/_meter_provider.py | 10 ++++-- .../configuration/_tracer_provider.py | 5 +-- .../tests/test_common.py | 9 +++++ .../tests/test_logger_provider.py | 27 +++++++++++++++ .../tests/test_meter_provider.py | 30 +++++++++++++++++ .../tests/test_tracer_provider.py | 18 ++++++++++ 12 files changed, 166 insertions(+), 11 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py index aadfb24eb10..04a240f53a8 100644 --- a/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py +++ b/exporter/opentelemetry-exporter-otlp-common/src/opentelemetry/exporter/otlp/common/http.py @@ -42,6 +42,11 @@ ) +def _transport_timeout(timeout: float) -> float | None: + """Map an infinite timeout to ``None``, which transports treat as no timeout.""" + return None if math.isinf(timeout) else timeout + + def _is_retryable(status_code: int | None) -> bool: return status_code in _RETRYABLE_STATUS_CODES @@ -156,7 +161,7 @@ def _submit(self, data: bytes, timeout: float) -> BaseHTTPResult: self._endpoint, headers=self._headers, data=data, - timeout=timeout, + timeout=_transport_timeout(timeout), ) if ( result.error is not None @@ -171,7 +176,7 @@ def _submit(self, data: bytes, timeout: float) -> BaseHTTPResult: self._endpoint, headers=self._headers, data=data, - timeout=remaining, + timeout=_transport_timeout(remaining), ) return result diff --git a/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py b/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py index 76d79b29fcb..817871757b1 100644 --- a/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py +++ b/exporter/opentelemetry-exporter-otlp-common/tests/test_http_client.py @@ -4,6 +4,7 @@ # pylint: disable=unexpected-keyword-arg import gzip +import math import threading import unittest import zlib @@ -335,6 +336,38 @@ def test_export_timeout_decreases_per_retry(self): self.assertAlmostEqual(transport.requests[1]["timeout"], 9.0) self.assertAlmostEqual(transport.requests[2]["timeout"], 7.0) + def test_export_infinite_timeout_passes_no_timeout_to_transport(self): + shutdown_event = Mock(spec=threading.Event) + shutdown_event.is_set.return_value = False + transport = _TestHTTPTransport( + _TestHTTPResult(status_code=503, reason="Service Unavailable"), + _TestHTTPResult(status_code=503, reason="Service Unavailable"), + _TestHTTPResult(status_code=200, reason="OK"), + ) + client = self._client(transport, timeout=math.inf, jitter=0.0) + # pylint: disable-next=protected-access + client._shutdown_event = shutdown_event + + with _mock_clock(shutdown_event): + result = client.export(b"payload") + + self.assertTrue(result.success) + self.assertEqual([request["timeout"] for request in transport.requests], [None, None, None]) + + def test_export_connection_error_with_infinite_timeout_passes_no_timeout(self): + stale_error = RuntimeError("stale connection") + transport = _TestHTTPTransport( + _TestHTTPResult(error=stale_error), + _TestHTTPResult(status_code=200, reason="OK"), + connection_errors={stale_error}, + ) + client = self._client(transport, timeout=math.inf) + + result = client.export(b"payload") + + self.assertTrue(result.success) + self.assertEqual([request["timeout"] for request in transport.requests], [None, None]) + def test_export_backoff_exhausts_remaining_timeout(self): shutdown_event = Mock(spec=threading.Event) shutdown_event.is_set.return_value = False diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index 4aea624602b..a548f388743 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -9,6 +9,7 @@ """ +import math import os import random import threading @@ -259,7 +260,7 @@ class OTLPExporterMixin(ABC, Generic[SDKDataT, ExportServiceRequestT, ExportResu insecure: Connection type credentials: ChannelCredentials object for server authentication headers: Headers to send when exporting - timeout: Backend request timeout in seconds + timeout: Backend request timeout in seconds; ``math.inf`` disables the deadline compression: gRPC compression method to use channel_options: gRPC channel options """ @@ -433,7 +434,8 @@ def _export( self._client.Export( request=self._translate_data(data), metadata=self._headers, - timeout=deadline_sec - time(), + # An infinite deadline means no limit, which gRPC expresses as None. + timeout=None if math.isinf(deadline_sec) else deadline_sec - time(), ) return self._result.SUCCESS # type: ignore [reportReturnType] except RpcError as error: diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py index 1d8a47be84e..fed1f4b3d79 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py @@ -1,6 +1,7 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +import math import threading import time import unittest @@ -534,6 +535,14 @@ def test_timeout_set_correctly(self): self.assertEqual(mock_trace_service.num_requests, 2) self.assertAlmostEqual(after - before, 1.4, delta=0.5) + def test_infinite_timeout_sets_no_deadline(self): + # pylint: disable=protected-access + exporter = OTLPSpanExporterForTesting(insecure=True, timeout=math.inf) + exporter._client = Mock() + + self.assertEqual(exporter.export([self.span]), SpanExportResult.SUCCESS) + self.assertIsNone(exporter._client.Export.call_args.kwargs["timeout"]) + @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_TIMEOUT: "invalid"}) def test_invalid_timeout_from_env_uses_default(self): # pylint: disable=protected-access diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_common.py b/opentelemetry-configuration/src/opentelemetry/configuration/_common.py index a57c3e490c9..9bee19e4e31 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_common.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_common.py @@ -6,6 +6,7 @@ import dataclasses import inspect import logging +import math from collections.abc import Callable from fnmatch import fnmatchcase from typing import Any, Protocol, TypeVar @@ -154,6 +155,21 @@ def _resolve_component( raise ConfigurationError(f"No {component_type} type specified in config.") +def _resolve_export_timeout(timeout: int | None) -> float | None: + """Convert a config export timeout in milliseconds to seconds. + + Returns None if unset, letting the exporter read env vars. A value of 0 + means no limit and maps to ``math.inf``. + """ + match timeout: + case None: + return None + case 0: + return math.inf + case _: + return timeout / 1000.0 + + def _parse_headers( headers: list | None, headers_list: str | None, diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py index 3adeacf9a04..aa40b2fa1f0 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py @@ -10,6 +10,7 @@ _map_compression, _parse_headers, _parse_otlp_file_output_stream, + _resolve_export_timeout, _scope_name_matches_glob, load_entry_point, ) @@ -89,7 +90,7 @@ def _create_otlp_http_log_exporter( ) -> LogRecordExporter: """Create an OTLP HTTP log exporter from config.""" headers = _parse_headers(config.headers, config.headers_list) - timeout = (config.timeout / 1000.0) if config.timeout is not None else None + timeout = _resolve_export_timeout(config.timeout) match config.encoding: case None | OtlpHttpEncoding.protobuf: @@ -161,7 +162,7 @@ def _create_otlp_grpc_log_exporter( compression = _map_compression(config.compression, grpc.Compression) headers = _parse_headers(config.headers, config.headers_list) - timeout = (config.timeout / 1000.0) if config.timeout is not None else None + timeout = _resolve_export_timeout(config.timeout) return OTLPLogExporter( # type: ignore[return-value] endpoint=config.endpoint, diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py index 7e816c529ab..a571086b824 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py @@ -4,12 +4,14 @@ from __future__ import annotations import logging +import math from opentelemetry import metrics from opentelemetry.configuration._common import ( _map_compression, _parse_headers, _parse_otlp_file_output_stream, + _resolve_export_timeout, _scope_name_matches_glob, load_entry_point, ) @@ -275,7 +277,7 @@ def _create_otlp_http_metric_exporter( ) -> MetricExporter: """Create an OTLP HTTP metric exporter from config.""" headers = _parse_headers(config.headers, config.headers_list) - timeout = (config.timeout / 1000.0) if config.timeout is not None else None + timeout = _resolve_export_timeout(config.timeout) preferred_temporality = _map_temporality(config.temporality_preference) preferred_aggregation = _map_histogram_aggregation(config.default_histogram_aggregation) @@ -353,7 +355,7 @@ def _create_otlp_grpc_metric_exporter( compression = _map_compression(config.compression, grpc.Compression) headers = _parse_headers(config.headers, config.headers_list) - timeout = (config.timeout / 1000.0) if config.timeout is not None else None + timeout = _resolve_export_timeout(config.timeout) preferred_temporality = _map_temporality(config.temporality_preference) preferred_aggregation = _map_histogram_aggregation(config.default_histogram_aggregation) @@ -438,10 +440,12 @@ def _create_periodic_metric_reader( exporter = _create_push_metric_exporter(config.exporter) interval = config.interval if config.interval is not None else _DEFAULT_EXPORT_INTERVAL_MILLIS timeout = config.timeout if config.timeout is not None else _DEFAULT_EXPORT_TIMEOUT_MILLIS + # A timeout of 0 means no limit. + export_timeout_millis = math.inf if timeout == 0 else float(timeout) return PeriodicExportingMetricReader( exporter=exporter, export_interval_millis=float(interval), - export_timeout_millis=float(timeout), + export_timeout_millis=export_timeout_millis, ) diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py index c51086924b1..a00b39a5f50 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py @@ -11,6 +11,7 @@ _matches_glob, _parse_headers, _parse_otlp_file_output_stream, + _resolve_export_timeout, _scope_name_matches_glob, load_entry_point, ) @@ -125,7 +126,7 @@ def _create_otlp_http_span_exporter( ) -> SpanExporter: """Create an OTLP HTTP span exporter from config.""" headers = _parse_headers(config.headers, config.headers_list) - timeout = (config.timeout / 1000.0) if config.timeout is not None else None + timeout = _resolve_export_timeout(config.timeout) match config.encoding: case None | OtlpHttpEncoding.protobuf: @@ -197,7 +198,7 @@ def _create_otlp_grpc_span_exporter( compression = _map_compression(config.compression, grpc.Compression) headers = _parse_headers(config.headers, config.headers_list) - timeout = (config.timeout / 1000.0) if config.timeout is not None else None + timeout = _resolve_export_timeout(config.timeout) return OTLPSpanExporter( # type: ignore[return-value] endpoint=config.endpoint, diff --git a/opentelemetry-configuration/tests/test_common.py b/opentelemetry-configuration/tests/test_common.py index c07053f8c9a..45347119ee3 100644 --- a/opentelemetry-configuration/tests/test_common.py +++ b/opentelemetry-configuration/tests/test_common.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import inspect +import math import unittest from dataclasses import dataclass, field, fields from types import SimpleNamespace @@ -15,6 +16,7 @@ _parse_headers, _parse_otlp_file_output_stream, _resolve_component, + _resolve_export_timeout, load_entry_point, ) from opentelemetry.configuration._exceptions import ConfigurationError @@ -92,6 +94,13 @@ def test_both_empty_struct_and_none_list_returns_empty_dict(self): self.assertEqual(_parse_headers([], None), {}) +class TestResolveExportTimeout(unittest.TestCase): + def test_conversion(self): + for timeout, expected in ((None, None), (0, math.inf), (5000, 5.0), (1, 0.001)): + with self.subTest(timeout=timeout): + self.assertEqual(_resolve_export_timeout(timeout), expected) + + class TestLoadEntryPoint(unittest.TestCase): def test_returns_loaded_class(self): mock_class = MagicMock() diff --git a/opentelemetry-configuration/tests/test_logger_provider.py b/opentelemetry-configuration/tests/test_logger_provider.py index 2446a8f3e4e..982e06008bb 100644 --- a/opentelemetry-configuration/tests/test_logger_provider.py +++ b/opentelemetry-configuration/tests/test_logger_provider.py @@ -4,6 +4,7 @@ # Tests access private members of SDK classes to assert correct configuration. # pylint: disable=protected-access +import math import os import sys import unittest @@ -485,6 +486,32 @@ def test_otlp_grpc_exporter_endpoint(self): self.assertEqual(call_kwargs["endpoint"], "http://collector:4317") self.assertAlmostEqual(call_kwargs["timeout"], 10.0) + def test_otlp_zero_timeout_is_unlimited(self): + mock_exporter_cls = MagicMock() + mock_exporter_module = MagicMock() + mock_exporter_module.OTLPLogExporter = mock_exporter_cls + cases = ( + ( + LogRecordExporterConfig(otlp_http=OtlpHttpExporterConfig(timeout=0)), + { + "opentelemetry.exporter.otlp.proto.http": MagicMock(), + "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_exporter_module, + }, + ), + ( + LogRecordExporterConfig(otlp_grpc=OtlpGrpcExporterConfig(timeout=0)), + { + "grpc": MagicMock(), + "opentelemetry.exporter.otlp.proto.grpc._log_exporter": mock_exporter_module, + }, + ), + ) + for config, modules in cases: + with self.subTest(config=config), patch.dict(sys.modules, modules): + mock_exporter_cls.reset_mock() + _create_log_record_exporter(config) + self.assertEqual(mock_exporter_cls.call_args.kwargs["timeout"], math.inf) + class TestLogRecordLimits(unittest.TestCase): def test_default_limits(self): diff --git a/opentelemetry-configuration/tests/test_meter_provider.py b/opentelemetry-configuration/tests/test_meter_provider.py index 567cb523307..2a09f968036 100644 --- a/opentelemetry-configuration/tests/test_meter_provider.py +++ b/opentelemetry-configuration/tests/test_meter_provider.py @@ -4,6 +4,7 @@ # Tests access private members of SDK classes to assert correct configuration. # pylint: disable=protected-access,too-many-lines +import math import os import sys import unittest @@ -230,6 +231,15 @@ def test_periodic_reader_explicit_timeout(self): reader = provider._metric_readers[0] self.assertEqual(reader._export_timeout_millis, 10000.0) + def test_periodic_reader_zero_timeout_is_unlimited(self): + config = self._make_periodic_config( + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), + timeout=0, + ) + provider = create_meter_provider(config) + reader = provider._metric_readers[0] + self.assertEqual(reader._export_timeout_millis, math.inf) + def test_otlp_http_missing_package_raises(self): config = self._make_periodic_config(PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig())) with ( @@ -276,6 +286,26 @@ def test_otlp_http_created_with_endpoint(self): self.assertIsNone(kwargs["timeout"]) self.assertIsNone(kwargs["compression"]) + def test_otlp_http_zero_timeout_is_unlimited(self): + mock_exporter_cls = MagicMock() + mock_http_module = MagicMock() + mock_module = MagicMock() + mock_module.OTLPMetricExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.metric_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": mock_http_module, + }, + ): + config = self._make_periodic_config( + PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig(timeout=0)) + ) + create_meter_provider(config) + + self.assertEqual(mock_exporter_cls.call_args.kwargs["timeout"], math.inf) + def test_otlp_http_json_missing_package_raises(self): config = self._make_periodic_config( PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig(encoding=OtlpHttpEncoding.json)) diff --git a/opentelemetry-configuration/tests/test_tracer_provider.py b/opentelemetry-configuration/tests/test_tracer_provider.py index 05b91a3e830..8209466b07d 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -5,6 +5,7 @@ # pylint: disable=protected-access # pylint: disable=too-many-lines +import math import os import sys import unittest @@ -645,6 +646,23 @@ def test_otlp_http_created_with_endpoint(self): compression=None, ) + def test_otlp_http_zero_timeout_is_unlimited(self): + mock_exporter_cls = MagicMock() + mock_module = MagicMock() + mock_module.OTLPSpanExporter = mock_exporter_cls + + with patch.dict( + sys.modules, + { + "opentelemetry.exporter.otlp.proto.http.trace_exporter": mock_module, + "opentelemetry.exporter.otlp.proto.http": MagicMock(), + }, + ): + config = self._make_batch_config(SpanExporterConfig(otlp_http=OtlpHttpExporterConfig(timeout=0))) + create_tracer_provider(config) + + self.assertEqual(mock_exporter_cls.call_args.kwargs["timeout"], math.inf) + def test_otlp_http_json_missing_package_raises(self): config = self._make_batch_config( SpanExporterConfig(otlp_http=OtlpHttpExporterConfig(encoding=OtlpHttpEncoding.json)) From ae309e6b935a63b30ee8fa2d76153a0382074938 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Wed, 21 Oct 2026 20:48:20 -0400 Subject: [PATCH 2/3] add changelog fragment --- .changelog/5806.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5806.fixed diff --git a/.changelog/5806.fixed b/.changelog/5806.fixed new file mode 100644 index 00000000000..4a714a6ad47 --- /dev/null +++ b/.changelog/5806.fixed @@ -0,0 +1 @@ +`opentelemetry-configuration`: treat timeout of 0 as infinite- #5806 From 90b4805b335f1cea1bd4663c670f9a8a700989a9 Mon Sep 17 00:00:00 2001 From: Lukas Hering <40302054+herin049@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:33:51 -0400 Subject: [PATCH 3/3] Update .changelog/5806.fixed Co-authored-by: Riccardo Magliocchetti --- .changelog/5806.fixed | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changelog/5806.fixed b/.changelog/5806.fixed index 4a714a6ad47..5873f1e18be 100644 --- a/.changelog/5806.fixed +++ b/.changelog/5806.fixed @@ -1 +1 @@ -`opentelemetry-configuration`: treat timeout of 0 as infinite- #5806 +`opentelemetry-configuration`: treat timeout of 0 as infinite