Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5806.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-configuration`: treat timeout of 0 as infinite
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
)


def _transport_timeout(timeout: float) -> float | None:
"""Map an infinite timeout to ``None``, which transports treat as no timeout."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Map an infinite timeout to ``None``, which transports treat as no timeout."""
"""Map an infinite timeout (as set by declarative configuration) to ``None``, which transports treat as no timeout."""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite true, a user can still theoretically pass in timeout=math.inf in the exporter constructors.

return None if math.isinf(timeout) else timeout


def _is_retryable(status_code: int | None) -> bool:
return status_code in _RETRYABLE_STATUS_CODES

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# pylint: disable=unexpected-keyword-arg

import gzip
import math
import threading
import unittest
import zlib
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

"""

import math
import os
import random
import threading
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

import math
import threading
import time
import unittest
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
_map_compression,
_parse_headers,
_parse_otlp_file_output_stream,
_resolve_export_timeout,
_scope_name_matches_glob,
load_entry_point,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
_matches_glob,
_parse_headers,
_parse_otlp_file_output_stream,
_resolve_export_timeout,
_scope_name_matches_glob,
load_entry_point,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions opentelemetry-configuration/tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
27 changes: 27 additions & 0 deletions opentelemetry-configuration/tests/test_logger_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
30 changes: 30 additions & 0 deletions opentelemetry-configuration/tests/test_meter_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading