Repository: open-telemetry/opentelemetry-python
Pull request #4504: Delete duplicated OTLP Exporter tests, move them to the mixin unit test. Fix broken shutdown unit test.
Author: login: DylanRussell
Labels: name: Skip Changelog
description: PRs that do not require a CHANGELOG.md entry
Created: 2025-03-24T20:40:26Z | Merged: 2025-04-04T15:28:55Z

# Description

Move all OTLP exporter tests that are testing the underlying behavior in the mixin to the mixin unit tests, 
instead of having them specified multiple times in the Metric/Log/Trace exporters.

Fix the shutdown tests which were flaky, so that they just test whether a pending export call completes or not. 

## Type of change

Please delete options that are not relevant.

- Bug fix (non-breaking change which fixes an issue)

# How Has This Been Tested?

This change is just a refactor of the unit tests, to delete duplicate tests and fix a broken test.. No functionality is changing.

# Does This PR Require a Contrib Repo Change?
- [X] No.

# Checklist:

- [X] Followed the style guidelines of this project
- [ ] Changelogs have been updated
- I don't think a changelog update is necessary for this, but I can add one if needed.
- [X] Unit tests have been added
- [ ] Documentation has been updated



Diff:
diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py
index a31679fb0d5..8f3677784b1 100644
--- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py
+++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py
@@ -15,33 +15,20 @@
 # pylint: disable=too-many-lines
 
 import time
-from concurrent.futures import ThreadPoolExecutor
 from os.path import dirname
 from unittest import TestCase
 from unittest.mock import patch
 
-from google.protobuf.duration_pb2 import (  # pylint: disable=no-name-in-module
-    Duration,
-)
 from google.protobuf.json_format import MessageToDict
-from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
-    RetryInfo,
-)
-from grpc import ChannelCredentials, Compression, StatusCode, server
+from grpc import ChannelCredentials, Compression
 
 from opentelemetry._logs import SeverityNumber
 from opentelemetry.exporter.otlp.proto.common._internal import _encode_value
 from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
     OTLPLogExporter,
 )
-from opentelemetry.exporter.otlp.proto.grpc.version import __version__
 from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
     ExportLogsServiceRequest,
-    ExportLogsServiceResponse,
-)
-from opentelemetry.proto.collector.logs.v1.logs_service_pb2_grpc import (
-    LogsServiceServicer,
-    add_LogsServiceServicer_to_server,
 )
 from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
 from opentelemetry.proto.common.v1.common_pb2 import (
@@ -53,7 +40,6 @@
     Resource as OTLPResource,
 )
 from opentelemetry.sdk._logs import LogData, LogRecord
-from opentelemetry.sdk._logs.export import LogExportResult
 from opentelemetry.sdk.environment_variables import (
     OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE,
     OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE,
@@ -70,62 +56,9 @@
 THIS_DIR = dirname(__file__)
 
 
-class LogsServiceServicerUNAVAILABLEDelay(LogsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNAVAILABLE)
-
-        context.send_initial_metadata(
-            (("google.rpc.retryinfo-bin", RetryInfo().SerializeToString()),)
-        )
-        context.set_trailing_metadata(
-            (
-                (
-                    "google.rpc.retryinfo-bin",
-                    RetryInfo(
-                        retry_delay=Duration(nanos=int(1e7))
-                    ).SerializeToString(),
-                ),
-            )
-        )
-
-        return ExportLogsServiceResponse()
-
-
-class LogsServiceServicerUNAVAILABLE(LogsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNAVAILABLE)
-
-        return ExportLogsServiceResponse()
-
-
-class LogsServiceServicerSUCCESS(LogsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.OK)
-
-        return ExportLogsServiceResponse()
-
-
-class LogsServiceServicerALREADY_EXISTS(LogsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.ALREADY_EXISTS)
-
-        return ExportLogsServiceResponse()
-
-
 class TestOTLPLogExporter(TestCase):
     def setUp(self):
         self.exporter = OTLPLogExporter()
-
-        self.server = server(ThreadPoolExecutor(max_workers=10))
-
-        self.server.add_insecure_port("127.0.0.1:4317")
-
-        self.server.start()
-
         self.log_data_1 = LogData(
             log_record=LogRecord(
                 timestamp=int(time.time() * 1e9),
@@ -204,9 +137,6 @@ def setUp(self):
             ),
         )
 
-    def tearDown(self):
-        self.server.stop(None)
-
     def test_exporting(self):
         # pylint: disable=protected-access
         self.assertEqual(self.exporter._exporting, "logs")
@@ -296,145 +226,6 @@ def test_env_variables_with_only_certificate(
 
         mock_logger_error.assert_not_called()
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter.ssl_channel_credentials"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter._stub"
-    )
-    # pylint: disable=unused-argument
-    def test_no_credentials_error(
-        self, mock_ssl_channel, mock_secure, mock_stub
-    ):
-        OTLPLogExporter(insecure=False)
-        self.assertTrue(mock_ssl_channel.called)
-
-    # pylint: disable=no-self-use
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    def test_otlp_exporter_endpoint(self, mock_secure, mock_insecure):
-        expected_endpoint = "localhost:4317"
-        endpoints = [
-            (
-                "http://localhost:4317",
-                None,
-                mock_insecure,
-            ),
-            (
-                "localhost:4317",
-                None,
-                mock_secure,
-            ),
-            (
-                "http://localhost:4317",
-                True,
-                mock_insecure,
-            ),
-            (
-                "localhost:4317",
-                True,
-                mock_insecure,
-            ),
-            (
-                "http://localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                None,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                True,
-                mock_secure,
-            ),
-        ]
-
-        # pylint: disable=C0209
-        for endpoint, insecure, mock_method in endpoints:
-            OTLPLogExporter(endpoint=endpoint, insecure=insecure)
-            self.assertEqual(
-                1,
-                mock_method.call_count,
-                "expected {} to be called for {} {}".format(
-                    mock_method, endpoint, insecure
-                ),
-            )
-            self.assertEqual(
-                expected_endpoint,
-                mock_method.call_args[0][0],
-                "expected {} got {} {}".format(
-                    expected_endpoint, mock_method.call_args[0][0], endpoint
-                ),
-            )
-            mock_method.reset_mock()
-
-    def test_otlp_headers_from_env(self):
-        # pylint: disable=protected-access
-        self.assertEqual(
-            self.exporter._headers,
-            (("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),),
-        )
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    def test_unavailable(self, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0.01]})
-
-        add_LogsServiceServicer_to_server(
-            LogsServiceServicerUNAVAILABLE(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.log_data_1]), LogExportResult.FAILURE
-        )
-        mock_sleep.assert_called_with(0.01)
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    def test_unavailable_delay(self, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [1]})
-
-        add_LogsServiceServicer_to_server(
-            LogsServiceServicerUNAVAILABLEDelay(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.log_data_1]), LogExportResult.FAILURE
-        )
-        mock_sleep.assert_called_with(0.01)
-
-    def test_success(self):
-        add_LogsServiceServicer_to_server(
-            LogsServiceServicerSUCCESS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.log_data_1]), LogExportResult.SUCCESS
-        )
-
-    def test_failure(self):
-        add_LogsServiceServicer_to_server(
-            LogsServiceServicerALREADY_EXISTS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.log_data_1]), LogExportResult.FAILURE
-        )
-
     def export_log_and_deserialize(self, log_data):
         # pylint: disable=protected-access
         translated_data = self.exporter._translate_data([log_data])
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 d9b02611a07..656d9a6cb79 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
@@ -13,10 +13,10 @@
 # limitations under the License.
 
 import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
 from logging import WARNING
-from time import time_ns
-from types import MethodType
-from typing import Sequence
+from typing import Any, Optional, Sequence
 from unittest import TestCase
 from unittest.mock import Mock, patch
 
@@ -26,20 +26,215 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, StatusCode, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
-    StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+    def shutdown(self, timeout_millis=30_000):
+        return OTLPExporterMixin.shutdown(self, timeout_millis)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result: StatusCode,
+        optional_export_sleep: Optional[float] = None,
+        optional_export_retry_millis: Optional[float] = None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry_millis:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry_millis)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        target=None,
+        args=(),
+    ):
+        super().__init__(target=target, args=args)
+        self._return = None
+
+    def run(self):
+        try:
+            if self._target is not None:  # type: ignore
+                self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+        finally:
+            # Avoid a refcycle if the thread is running a function with
+            # an argument that has a member that points to the thread.
+            del self._target, self._args, self._kwargs  # type: ignore
+
+    def join(self, timeout: Optional[float] = None) -> Any:
+        super().join(timeout=timeout)
+        return self._return
 
 
 class TestOTLPExporterMixin(TestCase):
+    def setUp(self):
+        self.server = server(ThreadPoolExecutor(max_workers=10))
+
+        self.server.add_insecure_port("127.0.0.1:4317")
+
+        self.server.start()
+        self.exporter = OTLPSpanExporterForTesting(insecure=True)
+        self.span = _Span(
+            "a",
+            context=Mock(
+                **{
+                    "trace_state": {"a": "b", "c": "d"},
+                    "span_id": 10217189687419569865,
+                    "trace_id": 67545097771067222548457157018666467027,
+                }
+            ),
+        )
+
+    def tearDown(self):
+        self.server.stop(None)
+
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
+    def test_otlp_exporter_endpoint(self, mock_secure, mock_insecure):
+        expected_endpoint = "localhost:4317"
+        endpoints = [
+            (
+                "http://localhost:4317",
+                None,
+                mock_insecure,
+            ),
+            (
+                "localhost:4317",
+                None,
+                mock_secure,
+            ),
+            (
+                "http://localhost:4317",
+                True,
+                mock_insecure,
+            ),
+            (
+                "localhost:4317",
+                True,
+                mock_insecure,
+            ),
+            (
+                "http://localhost:4317",
+                False,
+                mock_secure,
+            ),
+            (
+                "localhost:4317",
+                False,
+                mock_secure,
+            ),
+            (
+                "https://localhost:4317",
+                False,
+                mock_secure,
+            ),
+            (
+                "https://localhost:4317",
+                None,
+                mock_secure,
+            ),
+            (
+                "https://localhost:4317",
+                True,
+                mock_secure,
+            ),
+        ]
+        for endpoint, insecure, mock_method in endpoints:
+            OTLPSpanExporterForTesting(endpoint=endpoint, insecure=insecure)
+            self.assertEqual(
+                1,
+                mock_method.call_count,
+                f"expected {mock_method} to be called for {endpoint} {insecure}",
+            )
+            self.assertEqual(
+                expected_endpoint,
+                mock_method.call_args[0][0],
+                f"expected {expected_endpoint} got {mock_method.call_args[0][0]} {endpoint}",
+            )
+            mock_method.reset_mock()
+
     def test_environ_to_compression(self):
         with patch.dict(
             "os.environ",
@@ -64,147 +259,187 @@ def test_environ_to_compression(self):
             with self.assertRaises(InvalidCompressionValueException):
                 environ_to_compression("test_invalid")
 
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {})
+    def test_otlp_exporter_otlp_compression_unspecified(
+        self, mock_insecure_channel
+    ):
+        """No env or kwarg should be NoCompression"""
+        OTLPSpanExporterForTesting(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.NoCompression
+        )
+
+    # pylint: disable=no-self-use, disable=unused-argument
     @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
+        "opentelemetry.exporter.otlp.proto.grpc.exporter.ssl_channel_credentials"
     )
-    def test_export_warning(self, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0]})
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
+    @patch.dict("os.environ", {})
+    def test_no_credentials_ssl_channel_called(
+        self, secure_channel, mock_ssl_channel
+    ):
+        OTLPSpanExporterForTesting(insecure=False)
+        self.assertTrue(mock_ssl_channel.called)
+
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
+    def test_otlp_exporter_otlp_compression_envvar(
+        self, mock_insecure_channel
+    ):
+        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
+        OTLPSpanExporterForTesting(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.Gzip
+        )
 
-        rpc_error = RpcError()
+    def test_shutdown(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
+        self.exporter.shutdown()
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
+            )
+            self.assertEqual(
+                warning.records[0].message,
+                "Exporter already shutdown, ignoring batch",
+            )
 
-        def code(self):
-            return None
+    def test_shutdown_wait_last_export(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(
+                StatusCode.OK, optional_export_sleep=1
+            ),
+            self.server,
+        )
 
-        rpc_error.code = MethodType(code, rpc_error)
+        export_thread = ThreadWithReturnValue(
+            target=self.exporter.export, args=([self.span],)
+        )
+        export_thread.start()
+        # Wait a bit for exporter to get lock and make export call.
+        time.sleep(0.25)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._export_lock.locked())
+        self.exporter.shutdown(timeout_millis=3000)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._shutdown)
+        self.assertEqual(export_thread.join(), SpanExportResult.SUCCESS)
+
+    def test_shutdown_doesnot_wait_last_export(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(
+                StatusCode.OK, optional_export_sleep=3
+            ),
+            self.server,
+        )
 
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = Mock()
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
-            )
+        export_thread = ThreadWithReturnValue(
+            target=self.exporter.export, args=([self.span],)
+        )
+        export_thread.start()
+        # Wait for exporter to get lock and make export call.
+        time.sleep(0.25)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._export_lock.locked())
+        # Set to 1 seconds, so the 3 second server-side delay will not be reached.
+        self.exporter.shutdown(timeout_millis=1000)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._shutdown)
+        self.assertEqual(export_thread.join(), None)
 
-            def _translate_data(
-                self, data: Sequence[SDKDataT]
-            ) -> ExportServiceRequestT:
-                pass
+    def test_export_over_closed_grpc_channel(self):
+        # pylint: disable=protected-access
 
-            @property
-            def _exporting(self) -> str:
-                return "mock"
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.exporter.export([self.span])
+        self.exporter.shutdown()
+        data = self.exporter._translate_data([self.span])
+        with self.assertRaises(ValueError) as err:
+            self.exporter._client.Export(request=data)
+        self.assertEqual(
+            str(err.exception), "Cannot invoke RPC on closed channel!"
+        )
 
-        otlp_mock_exporter = OTLPMockExporter()
+    @patch(
+        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
+    )
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
+    def test_unavailable(self, mock_sleep, mock_expo):
+        mock_expo.configure_mock(**{"return_value": [0.01]})
 
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.UNAVAILABLE),
+            self.server,
+        )
+        result = self.exporter.export([self.span])
+        self.assertEqual(result, SpanExportResult.FAILURE)
+        mock_sleep.assert_called_with(0.01)
+
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
+    def test_unavailable_delay(self, mock_sleep):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(
+                StatusCode.UNAVAILABLE,
+                optional_export_sleep=None,
+                optional_export_retry_millis=1e7,
+            ),
+            self.server,
+        )
         with self.assertLogs(level=WARNING) as warning:
-            # pylint: disable=protected-access
-            otlp_mock_exporter._export(Mock())
             self.assertEqual(
-                warning.records[0].message,
-                "Failed to export mock to localhost:4317, error code: None",
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
             )
+            mock_sleep.assert_called_with(0.01)
 
-        def code(self):  # pylint: disable=function-redefined
-            return StatusCode.CANCELLED
-
-        def trailing_metadata(self):
-            return {}
-
-        rpc_error.code = MethodType(code, rpc_error)
-        rpc_error.trailing_metadata = MethodType(trailing_metadata, rpc_error)
-
-        with self.assertLogs(level=WARNING) as warning:
-            # pylint: disable=protected-access
-            otlp_mock_exporter._export([])
             self.assertEqual(
                 warning.records[0].message,
                 (
-                    "Transient error StatusCode.CANCELLED encountered "
-                    "while exporting mock to localhost:4317, retrying in 0s."
+                    "Transient error StatusCode.UNAVAILABLE encountered "
+                    "while exporting traces to localhost:4317, retrying in 0.01s."
                 ),
             )
 
-    def test_shutdown(self):
-        result_mock = Mock()
-
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = result_mock
-            _stub = Mock(**{"return_value": Mock()})
-
-            def _translate_data(
-                self, data: Sequence[SDKDataT]
-            ) -> ExportServiceRequestT:
-                pass
-
-            @property
-            def _exporting(self) -> str:
-                return "mock"
+    def test_success(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
 
-        otlp_mock_exporter = OTLPMockExporter()
+    def test_otlp_headers_from_env(self):
+        # pylint: disable=protected-access
+        # This ensures that there is no other header than standard user-agent.
+        self.assertEqual(
+            self.exporter._headers,
+            (("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),),
+        )
 
+    def test_permanent_failure(self):
         with self.assertLogs(level=WARNING) as warning:
-            # pylint: disable=protected-access
-            self.assertEqual(
-                otlp_mock_exporter._export(data={}), result_mock.SUCCESS
+            add_TraceServiceServicer_to_server(
+                TraceServiceServicerWithExportParams(
+                    StatusCode.ALREADY_EXISTS
+                ),
+                self.server,
             )
-            otlp_mock_exporter.shutdown()
-            # pylint: disable=protected-access
             self.assertEqual(
-                otlp_mock_exporter._export(data={}), result_mock.FAILURE
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
             )
             self.assertEqual(
                 warning.records[0].message,
-                "Exporter already shutdown, ignoring batch",
+                "Failed to export traces to localhost:4317, error code: StatusCode.ALREADY_EXISTS",
             )
-
-    def test_shutdown_wait_last_export(self):
-        result_mock = Mock()
-        rpc_error = RpcError()
-
-        def code(self):
-            return StatusCode.UNAVAILABLE
-
-        def trailing_metadata(self):
-            return {
-                "google.rpc.retryinfo-bin": RetryInfo(
-                    retry_delay=Duration(nanos=int(1e7))
-                ).SerializeToString()
-            }
-
-        rpc_error.code = MethodType(code, rpc_error)
-        rpc_error.trailing_metadata = MethodType(trailing_metadata, rpc_error)
-
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = result_mock
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
-            )
-
-            def _translate_data(
-                self, data: Sequence[SDKDataT]
-            ) -> ExportServiceRequestT:
-                pass
-
-            @property
-            def _exporting(self) -> str:
-                return "mock"
-
-        otlp_mock_exporter = OTLPMockExporter()
-
-        # pylint: disable=protected-access
-        export_thread = threading.Thread(
-            target=otlp_mock_exporter._export, args=({},)
-        )
-        export_thread.start()
-        try:
-            # pylint: disable=protected-access
-            self.assertTrue(otlp_mock_exporter._export_lock.locked())
-            # delay is 1 second while the default shutdown timeout is 30_000 milliseconds
-            start_time = time_ns()
-            otlp_mock_exporter.shutdown()
-            now = time_ns()
-            self.assertGreaterEqual(now, (start_time + 30 / 1000))
-            # pylint: disable=protected-access
-            self.assertTrue(otlp_mock_exporter._shutdown)
-            # pylint: disable=protected-access
-            self.assertFalse(otlp_mock_exporter._export_lock.locked())
-        finally:
-            export_thread.join()
diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py
index 9cd7ac38358..2ea12f660fb 100644
--- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py
+++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py
@@ -12,39 +12,20 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# pylint: disable=too-many-lines
-
-import threading
-from concurrent.futures import ThreadPoolExecutor
-
 # pylint: disable=too-many-lines
 from logging import WARNING
 from os import environ
 from os.path import dirname
-from time import time_ns
 from typing import List
 from unittest import TestCase
 from unittest.mock import patch
 
-from google.protobuf.duration_pb2 import (  # pylint: disable=no-name-in-module
-    Duration,
-)
-from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
-    RetryInfo,
-)
-from grpc import ChannelCredentials, Compression, StatusCode, server
+from grpc import ChannelCredentials, Compression
 
 from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
     OTLPMetricExporter,
 )
 from opentelemetry.exporter.otlp.proto.grpc.version import __version__
-from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
-    ExportMetricsServiceResponse,
-)
-from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2_grpc import (
-    MetricsServiceServicer,
-    add_MetricsServiceServicer_to_server,
-)
 from opentelemetry.proto.common.v1.common_pb2 import InstrumentationScope
 from opentelemetry.sdk.environment_variables import (
     OTEL_EXPORTER_OTLP_COMPRESSION,
@@ -71,7 +52,6 @@
     AggregationTemporality,
     Gauge,
     Metric,
-    MetricExportResult,
     MetricsData,
     NumberDataPoint,
     ResourceMetrics,
@@ -90,72 +70,12 @@
 THIS_DIR = dirname(__file__)
 
 
-class MetricsServiceServicerUNAVAILABLEDelay(MetricsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNAVAILABLE)
-
-        context.send_initial_metadata(
-            (("google.rpc.retryinfo-bin", RetryInfo().SerializeToString()),)
-        )
-        context.set_trailing_metadata(
-            (
-                (
-                    "google.rpc.retryinfo-bin",
-                    RetryInfo(
-                        retry_delay=Duration(nanos=int(1e7))
-                    ).SerializeToString(),
-                ),
-            )
-        )
-
-        return ExportMetricsServiceResponse()
-
-
-class MetricsServiceServicerUNAVAILABLE(MetricsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNAVAILABLE)
-
-        return ExportMetricsServiceResponse()
-
-
-class MetricsServiceServicerUNKNOWN(MetricsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNKNOWN)
-
-        return ExportMetricsServiceResponse()
-
-
-class MetricsServiceServicerSUCCESS(MetricsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.OK)
-
-        return ExportMetricsServiceResponse()
-
-
-class MetricsServiceServicerALREADY_EXISTS(MetricsServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.ALREADY_EXISTS)
-
-        return ExportMetricsServiceResponse()
-
-
 class TestOTLPMetricExporter(TestCase):
     # pylint: disable=too-many-public-methods
 
     def setUp(self):
         self.exporter = OTLPMetricExporter()
 
-        self.server = server(ThreadPoolExecutor(max_workers=10))
-
-        self.server.add_insecure_port("127.0.0.1:4317")
-
-        self.server.start()
-
         self.metrics = {
             "sum_int": MetricsData(
                 resource_metrics=[
@@ -181,9 +101,6 @@ def setUp(self):
             )
         }
 
-    def tearDown(self):
-        self.server.stop(None)
-
     def test_exporting(self):
         # pylint: disable=protected-access
         self.assertEqual(self.exporter._exporting, "metrics")
@@ -371,92 +288,6 @@ def test_otlp_insecure_from_env(self, mock_insecure):
             f"expected {mock_insecure} to be called",
         )
 
-    # pylint: disable=no-self-use
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    def test_otlp_exporter_endpoint(self, mock_secure, mock_insecure):
-        expected_endpoint = "localhost:4317"
-        endpoints = [
-            (
-                "http://localhost:4317",
-                None,
-                mock_insecure,
-            ),
-            (
-                "localhost:4317",
-                None,
-                mock_secure,
-            ),
-            (
-                "http://localhost:4317",
-                True,
-                mock_insecure,
-            ),
-            (
-                "localhost:4317",
-                True,
-                mock_insecure,
-            ),
-            (
-                "http://localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                None,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                True,
-                mock_secure,
-            ),
-        ]
-        # pylint: disable=C0209
-        for endpoint, insecure, mock_method in endpoints:
-            OTLPMetricExporter(endpoint=endpoint, insecure=insecure)
-            self.assertEqual(
-                1,
-                mock_method.call_count,
-                "expected {} to be called for {} {}".format(
-                    mock_method, endpoint, insecure
-                ),
-            )
-            self.assertEqual(
-                expected_endpoint,
-                mock_method.call_args[0][0],
-                "expected {} got {} {}".format(
-                    expected_endpoint, mock_method.call_args[0][0], endpoint
-                ),
-            )
-            mock_method.reset_mock()
-
-    # pylint: disable=no-self-use
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
-    def test_otlp_exporter_otlp_compression_envvar(
-        self, mock_insecure_channel, mock_expo
-    ):
-        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
-        OTLPMetricExporter(insecure=True)
-        mock_insecure_channel.assert_called_once_with(
-            "localhost:4317", compression=Compression.Gzip
-        )
-
     # pylint: disable=no-self-use
     @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
     @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
@@ -469,92 +300,6 @@ def test_otlp_exporter_otlp_compression_kwarg(self, mock_insecure_channel):
             "localhost:4317", compression=Compression.NoCompression
         )
 
-    # pylint: disable=no-self-use
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch.dict("os.environ", {})
-    def test_otlp_exporter_otlp_compression_unspecified(
-        self, mock_insecure_channel
-    ):
-        """No env or kwarg should be NoCompression"""
-        OTLPMetricExporter(insecure=True)
-        mock_insecure_channel.assert_called_once_with(
-            "localhost:4317", compression=Compression.NoCompression
-        )
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    def test_unavailable(self, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0.01]})
-
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerUNAVAILABLE(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export(self.metrics["sum_int"]),
-            MetricExportResult.FAILURE,
-        )
-        mock_sleep.assert_called_with(0.01)
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    def test_unavailable_delay(self, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [1]})
-
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerUNAVAILABLEDelay(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export(self.metrics["sum_int"]),
-            MetricExportResult.FAILURE,
-        )
-        mock_sleep.assert_called_with(0.01)
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.logger.error")
-    def test_unknown_logs(self, mock_logger_error, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [1]})
-
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerUNKNOWN(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export(self.metrics["sum_int"]),
-            MetricExportResult.FAILURE,
-        )
-        mock_sleep.assert_not_called()
-        mock_logger_error.assert_called_with(
-            "Failed to export %s to %s, error code: %s",
-            "metrics",
-            "localhost:4317",
-            StatusCode.UNKNOWN,
-            exc_info=True,
-        )
-
-    def test_success(self):
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerSUCCESS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export(self.metrics["sum_int"]),
-            MetricExportResult.SUCCESS,
-        )
-
-    def test_failure(self):
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerALREADY_EXISTS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export(self.metrics["sum_int"]),
-            MetricExportResult.FAILURE,
-        )
-
     def test_split_metrics_data_many_data_points(self):
         # GIVEN
         metrics_data = MetricsData(
@@ -830,65 +575,6 @@ def test_insecure_https_endpoint(self, mock_secure_channel):
         OTLPMetricExporter(endpoint="https://ab.c:123", insecure=True)
         mock_secure_channel.assert_called()
 
-    def test_shutdown(self):
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerSUCCESS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export(self.metrics["sum_int"]),
-            MetricExportResult.SUCCESS,
-        )
-        self.exporter.shutdown()
-        with self.assertLogs(level=WARNING) as warning:
-            self.assertEqual(
-                self.exporter.export(self.metrics["sum_int"]),
-                MetricExportResult.FAILURE,
-            )
-            self.assertEqual(
-                warning.records[0].message,
-                "Exporter already shutdown, ignoring batch",
-            )
-        self.exporter = OTLPMetricExporter()
-
-    def test_shutdown_wait_last_export(self):
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerUNAVAILABLEDelay(), self.server
-        )
-
-        export_thread = threading.Thread(
-            target=self.exporter.export, args=(self.metrics["sum_int"],)
-        )
-        export_thread.start()
-        try:
-            # pylint: disable=protected-access
-            self.assertTrue(self.exporter._export_lock.locked())
-            # delay is 4 seconds while the default shutdown timeout is 30_000 milliseconds
-            start_time = time_ns()
-            self.exporter.shutdown()
-            now = time_ns()
-            self.assertGreaterEqual(now, (start_time + 30 / 1000))
-            # pylint: disable=protected-access
-            self.assertTrue(self.exporter._shutdown)
-            # pylint: disable=protected-access
-            self.assertFalse(self.exporter._export_lock.locked())
-        finally:
-            export_thread.join()
-
-    def test_export_over_closed_grpc_channel(self):
-        # pylint: disable=protected-access
-
-        add_MetricsServiceServicer_to_server(
-            MetricsServiceServicerSUCCESS(), self.server
-        )
-        self.exporter.export(self.metrics["sum_int"])
-        self.exporter.shutdown()
-        data = self.exporter._translate_data(self.metrics["sum_int"])
-        with self.assertRaises(ValueError) as err:
-            self.exporter._client.Export(request=data)
-        self.assertEqual(
-            str(err.exception), "Cannot invoke RPC on closed channel!"
-        )
-
     def test_aggregation_temporality(self):
         # pylint: disable=protected-access
 
diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py
index f29b7fc611c..73d8d6c7a20 100644
--- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py
+++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py
@@ -15,20 +15,10 @@
 # pylint: disable=too-many-lines
 
 import os
-import threading
-from concurrent.futures import ThreadPoolExecutor
-from logging import WARNING
-from time import time_ns
 from unittest import TestCase
 from unittest.mock import Mock, PropertyMock, patch
 
-from google.protobuf.duration_pb2 import (  # pylint: disable=no-name-in-module
-    Duration,
-)
-from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
-    RetryInfo,
-)
-from grpc import ChannelCredentials, Compression, StatusCode, server
+from grpc import ChannelCredentials, Compression
 
 from opentelemetry.attributes import BoundedAttributes
 from opentelemetry.exporter.otlp.proto.common._internal import (
@@ -40,11 +30,6 @@
 from opentelemetry.exporter.otlp.proto.grpc.version import __version__
 from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
     ExportTraceServiceRequest,
-    ExportTraceServiceResponse,
-)
-from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
-    TraceServiceServicer,
-    add_TraceServiceServicer_to_server,
 )
 from opentelemetry.proto.common.v1.common_pb2 import (
     AnyValue,
@@ -80,7 +65,6 @@
 from opentelemetry.sdk.trace import TracerProvider, _Span
 from opentelemetry.sdk.trace.export import (
     SimpleSpanProcessor,
-    SpanExportResult,
 )
 from opentelemetry.sdk.util.instrumentation import InstrumentationScope
 from opentelemetry.test.spantestutil import (
@@ -90,52 +74,6 @@
 THIS_DIR = os.path.dirname(__file__)
 
 
-class TraceServiceServicerUNAVAILABLEDelay(TraceServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNAVAILABLE)
-
-        context.send_initial_metadata(
-            (("google.rpc.retryinfo-bin", RetryInfo().SerializeToString()),)
-        )
-        context.set_trailing_metadata(
-            (
-                (
-                    "google.rpc.retryinfo-bin",
-                    RetryInfo(
-                        retry_delay=Duration(nanos=int(1e7))
-                    ).SerializeToString(),
-                ),
-            )
-        )
-
-        return ExportTraceServiceResponse()
-
-
-class TraceServiceServicerUNAVAILABLE(TraceServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.UNAVAILABLE)
-
-        return ExportTraceServiceResponse()
-
-
-class TraceServiceServicerSUCCESS(TraceServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.OK)
-
-        return ExportTraceServiceResponse()
-
-
-class TraceServiceServicerALREADY_EXISTS(TraceServiceServicer):
-    # pylint: disable=invalid-name,unused-argument,no-self-use
-    def Export(self, request, context):
-        context.set_code(StatusCode.ALREADY_EXISTS)
-
-        return ExportTraceServiceResponse()
-
-
 class TestOTLPSpanExporter(TestCase):
     # pylint: disable=too-many-public-methods
 
@@ -145,12 +83,6 @@ def setUp(self):
         tracer_provider.add_span_processor(SimpleSpanProcessor(self.exporter))
         self.tracer = tracer_provider.get_tracer(__name__)
 
-        self.server = server(ThreadPoolExecutor(max_workers=10))
-
-        self.server.add_insecure_port("127.0.0.1:4317")
-
-        self.server.start()
-
         event_mock = Mock(
             **{
                 "timestamp": 1591240820506462784,
@@ -232,9 +164,6 @@ def setUp(self):
         self.span3.start()
         self.span3.end()
 
-    def tearDown(self):
-        self.server.stop(None)
-
     def test_exporting(self):
         # pylint: disable=protected-access
         self.assertEqual(self.exporter._exporting, "traces")
@@ -397,85 +326,6 @@ def test_otlp_insecure_from_env(self, mock_insecure):
             f"expected {mock_insecure} to be called",
         )
 
-    # pylint: disable=no-self-use
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    def test_otlp_exporter_endpoint(self, mock_secure, mock_insecure):
-        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
-        expected_endpoint = "localhost:4317"
-        endpoints = [
-            (
-                "http://localhost:4317",
-                None,
-                mock_insecure,
-            ),
-            (
-                "localhost:4317",
-                None,
-                mock_secure,
-            ),
-            (
-                "http://localhost:4317",
-                True,
-                mock_insecure,
-            ),
-            (
-                "localhost:4317",
-                True,
-                mock_insecure,
-            ),
-            (
-                "http://localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                False,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                None,
-                mock_secure,
-            ),
-            (
-                "https://localhost:4317",
-                True,
-                mock_secure,
-            ),
-        ]
-        for endpoint, insecure, mock_method in endpoints:
-            OTLPSpanExporter(endpoint=endpoint, insecure=insecure)
-            self.assertEqual(
-                1,
-                mock_method.call_count,
-                f"expected {mock_method} to be called for {endpoint} {insecure}",
-            )
-            self.assertEqual(
-                expected_endpoint,
-                mock_method.call_args[0][0],
-                f"expected {expected_endpoint} got {mock_method.call_args[0][0]} {endpoint}",
-            )
-            mock_method.reset_mock()
-
-    # pylint: disable=no-self-use
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
-    def test_otlp_exporter_otlp_compression_envvar(
-        self, mock_insecure_channel
-    ):
-        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
-        OTLPSpanExporter(insecure=True)
-        mock_insecure_channel.assert_called_once_with(
-            "localhost:4317", compression=Compression.Gzip
-        )
-
     # pylint: disable=no-self-use
     @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
     @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
@@ -486,18 +336,6 @@ def test_otlp_exporter_otlp_compression_kwarg(self, mock_insecure_channel):
             "localhost:4317", compression=Compression.NoCompression
         )
 
-    # pylint: disable=no-self-use
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
-    @patch.dict("os.environ", {})
-    def test_otlp_exporter_otlp_compression_unspecified(
-        self, mock_insecure_channel
-    ):
-        """No env or kwarg should be NoCompression"""
-        OTLPSpanExporter(insecure=True)
-        mock_insecure_channel.assert_called_once_with(
-            "localhost:4317", compression=Compression.NoCompression
-        )
-
     # pylint: disable=no-self-use
     @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
     @patch.dict(
@@ -515,65 +353,6 @@ def test_otlp_exporter_otlp_compression_precendence(
             "localhost:4317", compression=Compression.Gzip
         )
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter.ssl_channel_credentials"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    # pylint: disable=unused-argument
-    def test_otlp_headers(self, mock_ssl_channel, mock_secure):
-        exporter = OTLPSpanExporter()
-        # pylint: disable=protected-access
-        # This ensures that there is no other header than standard user-agent.
-        self.assertEqual(
-            exporter._headers,
-            (("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),),
-        )
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    def test_unavailable(self, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0.01]})
-
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerUNAVAILABLE(), self.server
-        )
-        result = self.exporter.export([self.span])
-        self.assertEqual(result, SpanExportResult.FAILURE)
-        mock_sleep.assert_called_with(0.01)
-
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.sleep")
-    def test_unavailable_delay(self, mock_sleep, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [1]})
-
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerUNAVAILABLEDelay(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.span]), SpanExportResult.FAILURE
-        )
-        mock_sleep.assert_called_with(0.01)
-
-    def test_success(self):
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerSUCCESS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.span]), SpanExportResult.SUCCESS
-        )
-
-    def test_failure(self):
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerALREADY_EXISTS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.span]), SpanExportResult.FAILURE
-        )
-
     def test_translate_spans(self):
         expected = ExportTraceServiceRequest(
             resource_spans=[
@@ -976,62 +755,6 @@ def test_dropped_values(self):
             .dropped_attributes_count,
         )
 
-    def test_shutdown(self):
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerSUCCESS(), self.server
-        )
-        self.assertEqual(
-            self.exporter.export([self.span]), SpanExportResult.SUCCESS
-        )
-        self.exporter.shutdown()
-        with self.assertLogs(level=WARNING) as warning:
-            self.assertEqual(
-                self.exporter.export([self.span]), SpanExportResult.FAILURE
-            )
-            self.assertEqual(
-                warning.records[0].message,
-                "Exporter already shutdown, ignoring batch",
-            )
-
-    def test_shutdown_wait_last_export(self):
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerUNAVAILABLEDelay(), self.server
-        )
-
-        export_thread = threading.Thread(
-            target=self.exporter.export, args=([self.span],)
-        )
-        export_thread.start()
-        try:
-            # pylint: disable=protected-access
-            self.assertTrue(self.exporter._export_lock.locked())
-            # delay is 4 seconds while the default shutdown timeout is 30_000 milliseconds
-            start_time = time_ns()
-            self.exporter.shutdown()
-            now = time_ns()
-            self.assertGreaterEqual(now, (start_time + 30 / 1000))
-            # pylint: disable=protected-access
-            self.assertTrue(self.exporter._shutdown)
-            # pylint: disable=protected-access
-            self.assertFalse(self.exporter._export_lock.locked())
-        finally:
-            export_thread.join()
-
-    def test_export_over_closed_grpc_channel(self):
-        # pylint: disable=protected-access
-
-        add_TraceServiceServicer_to_server(
-            TraceServiceServicerSUCCESS(), self.server
-        )
-        self.exporter.export([self.span])
-        self.exporter.shutdown()
-        data = self.exporter._translate_data([self.span])
-        with self.assertRaises(ValueError) as err:
-            self.exporter._client.Export(request=data)
-        self.assertEqual(
-            str(err.exception), "Cannot invoke RPC on closed channel!"
-        )
-
 
 def _create_span_with_status(status: SDKStatus):
     span = _Span(


Reviews:
user: login: aabmass
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T23:22:22Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:56:14Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T20:25:36Z
user: login: aabmass
body: Did you mostly rewrite the tests in `exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin` or does it copy from the old tests that were deleted here?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T21:45:11Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T14:34:46Z
user: login: aabmass
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T15:30:34Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T14:54:39Z
user: login: aabmass
body: If you can make the tests work without sleeping, it would be nice. If not, can you try to shorten the sleep durations a bit?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-02T20:27:44Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:58:52Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T21:02:49Z
user: login: xrmx
body: I like the reduction of work done, hopefully we are still confident that the exporters behave like the mixin :sweat_smile: A couple of nits but LGTM.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:42:44Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T16:50:53Z
user: login: jomcgi
body: Looks good to me! left some small nits.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T18:59:49Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:44:24Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:49:14Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:49:29Z
user: login: emdneto
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T21:22:40Z
user: login: emdneto
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T21:23:38Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T13:36:51Z
user: login: DylanRussell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:02:30Z

Review comments:
diff_hunk: @@ -352,15 +352,14 @@ def _export(
 
         return self._result.FAILURE
 
-    def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: aabmass
body: The `**kwargs` is in the super class signature so it's required. It assures that child classes won't break if new parameters are added in the super class signature

I guess we are missing it on the the actual exporter classes too 😓 
created_at: 2025-03-24T22:59:17Z
author_association: MEMBER
diff_hunk: @@ -289,11 +289,11 @@ def _export(
         # expo returns a generator that yields delay values which grow
         # exponentially. Once delay is greater than max_value, the yielded
         # value will remain constant.
-        for delay in _create_exp_backoff_generator(max_value=max_value):
-            if delay == max_value or self._shutdown:
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: aabmass
body: I think the lock was not held here before on purpose, so that shutdown can be set and it will exit the export loop early.

There is also another problem where shutdown gets blocked on the sleep at L337 (https://github.com/open-telemetry/opentelemetry-python/issues/3309 and PR we never reviewed https://github.com/open-telemetry/opentelemetry-python/pull/3764/files), but at least this way it won't block for more than max_value seconds. We can discuss that later I suppose.
created_at: 2025-03-24T23:19:48Z
author_association: MEMBER
diff_hunk: @@ -352,15 +352,14 @@ def _export(
 
         return self._result.FAILURE
 
-    def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
         if self._shutdown:
             logger.warning("Exporter already shutdown, ignoring call")
             return
         # wait for the last export if any
         self._export_lock.acquire(timeout=timeout_millis / 1e3)
         self._shutdown = True
         self._channel.close()
-        self._export_lock.release()
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: aabmass
body: Won't this leave the lock permanently acquired? I guess it's about to GC at this point anyway but still feels a little wrong.

See also https://github.com/open-telemetry/opentelemetry-python/issues/3398
created_at: 2025-03-24T23:22:20Z
author_association: MEMBER
diff_hunk: @@ -352,15 +352,14 @@ def _export(
 
         return self._result.FAILURE
 
-    def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: DylanRussell
body: Ah ok, added it back. Shall I add it to the exporter classes then ?
created_at: 2025-03-25T14:04:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -352,15 +352,14 @@ def _export(
 
         return self._result.FAILURE
 
-    def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
         if self._shutdown:
             logger.warning("Exporter already shutdown, ignoring call")
             return
         # wait for the last export if any
         self._export_lock.acquire(timeout=timeout_millis / 1e3)
         self._shutdown = True
         self._channel.close()
-        self._export_lock.release()
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: DylanRussell
body: Note that calling `acquire` with `timeout` means we proceed after `timeout` regardless of whether we acquired the lock: https://docs.python.org/3/library/threading.html#threading.Lock.acquire 

So we could change this to release the lock depending on if we acquired the lock or not. But it sounds like we want to use a conditional not a lock (see my other comment), WDYT ?
created_at: 2025-03-25T14:14:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -289,11 +289,11 @@ def _export(
         # expo returns a generator that yields delay values which grow
         # exponentially. Once delay is greater than max_value, the yielded
         # value will remain constant.
-        for delay in _create_exp_backoff_generator(max_value=max_value):
-            if delay == max_value or self._shutdown:
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: DylanRussell
body: It sounds like we want a Condition and not a Lock: https://docs.python.org/3/library/threading.html#condition-objects

When the `export` call gives up the lock, there is no guarantee that other threads waiting for the lock will acquire it. See https://stackoverflow.com/questions/65905098/python-thread-not-releasing-lock

created_at: 2025-03-25T14:52:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -352,15 +352,14 @@ def _export(
 
         return self._result.FAILURE
 
-    def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None:
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
         if self._shutdown:
             logger.warning("Exporter already shutdown, ignoring call")
             return
         # wait for the last export if any
         self._export_lock.acquire(timeout=timeout_millis / 1e3)
         self._shutdown = True
         self._channel.close()
-        self._export_lock.release()
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: DylanRussell
body: Resolving now that  I moved this stuff out of this PR
created_at: 2025-03-28T20:24:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +25,188 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
 from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
     InvalidCompressionValueException,
-    OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+    OTLPSpanExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExportResult,
+)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry = optional_export_retry
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        group=None,
+        target=None,
+        name=None,
+        args=(),
+        kwargs=None,
+    ):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: ```suggestion
        super().__init__(self, group, target, name, args, kwargs)
```
created_at: 2025-03-28T21:39:03Z
author_association: MEMBER
diff_hunk: @@ -26,20 +25,188 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
 from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
     InvalidCompressionValueException,
-    OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+    OTLPSpanExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExportResult,
+)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry = optional_export_retry
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        group=None,
+        target=None,
+        name=None,
+        args=(),
+        kwargs=None,
+    ):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
+        self._return = None
+
+    def run(self):
+        if self._target is not None:  # type: ignore
+            self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+
+    def join(self, *args):  # type: ignore
+        threading.Thread.join(self, *args)
+        return self._return
 
 
 class TestOTLPExporterMixin(TestCase):
+    def setUp(self):
+        self.server = server(ThreadPoolExecutor(max_workers=10))
+
+        self.server.add_insecure_port("127.0.0.1:4317")
+
+        self.server.start()
+        # The below tests use the SpanExporter and Spans, but are testing the
+        # underlying behavior in the mixin. A MetricExporter or LogExporter could
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: Consider making a minimal implementation of Span exporter in this file that uses the mixin for clarity and to isolate the behavior
created_at: 2025-03-28T21:40:24Z
author_association: MEMBER
diff_hunk: @@ -26,20 +25,188 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
 from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
     InvalidCompressionValueException,
-    OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+    OTLPSpanExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExportResult,
+)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry = optional_export_retry
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        group=None,
+        target=None,
+        name=None,
+        args=(),
+        kwargs=None,
+    ):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
+        self._return = None
+
+    def run(self):
+        if self._target is not None:  # type: ignore
+            self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+
+    def join(self, *args):  # type: ignore
+        threading.Thread.join(self, *args)
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: ```suggestion
        super().join(self, *args)
```
created_at: 2025-03-28T21:41:30Z
author_association: MEMBER
diff_hunk: @@ -64,147 +231,173 @@ def test_environ_to_compression(self):
             with self.assertRaises(InvalidCompressionValueException):
                 environ_to_compression("test_invalid")
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    def test_export_warning(self, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0]})
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {})
+    def test_otlp_exporter_otlp_compression_unspecified(
+        self, mock_insecure_channel
+    ):
+        """No env or kwarg should be NoCompression"""
+        OTLPSpanExporter(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.NoCompression
+        )
 
-        rpc_error = RpcError()
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
+    def test_otlp_exporter_otlp_compression_envvar(
+        self, mock_insecure_channel
+    ):
+        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
+        OTLPSpanExporter(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.Gzip
+        )
 
-        def code(self):
-            return None
+    def test_shutdown(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
+        self.exporter.shutdown()
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
+            )
+            self.assertEqual(
+                warning.records[0].message,
+                "Exporter already shutdown, ignoring batch",
+            )
 
-        rpc_error.code = MethodType(code, rpc_error)
+    def test_shutdown_wait_last_export(self):
+        # Shutdown waits 30 seconds for a pending Export call to finish.
+        # A 5 second delay is added, so it's expected that export will finish.
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK, 5),
+            self.server,
+        )
 
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = Mock()
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
-            )
+        export_thread = ThreadWithReturnValue(
+            target=self.exporter.export, args=([self.span],)
+        )
+        export_thread.start()
+        # Wait a sec for exporter to get lock and make export call.
+        time.sleep(1)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._export_lock.locked())
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: How do you klnow that the export lock will still be held?
created_at: 2025-03-28T21:43:38Z
author_association: MEMBER
diff_hunk: @@ -26,20 +25,188 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
 from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
     InvalidCompressionValueException,
-    OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+    OTLPSpanExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExportResult,
+)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry = optional_export_retry
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        group=None,
+        target=None,
+        name=None,
+        args=(),
+        kwargs=None,
+    ):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Nice. Done
created_at: 2025-03-31T14:28:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +25,188 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
 from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
     InvalidCompressionValueException,
-    OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+    OTLPSpanExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExportResult,
+)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry = optional_export_retry
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        group=None,
+        target=None,
+        name=None,
+        args=(),
+        kwargs=None,
+    ):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
+        self._return = None
+
+    def run(self):
+        if self._target is not None:  # type: ignore
+            self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+
+    def join(self, *args):  # type: ignore
+        threading.Thread.join(self, *args)
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Nice. Done
created_at: 2025-03-31T14:28:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +25,188 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
 from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
     InvalidCompressionValueException,
-    OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+    OTLPSpanExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExportResult,
+)
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry = optional_export_retry
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        group=None,
+        target=None,
+        name=None,
+        args=(),
+        kwargs=None,
+    ):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
+        self._return = None
+
+    def run(self):
+        if self._target is not None:  # type: ignore
+            self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+
+    def join(self, *args):  # type: ignore
+        threading.Thread.join(self, *args)
+        return self._return
 
 
 class TestOTLPExporterMixin(TestCase):
+    def setUp(self):
+        self.server = server(ThreadPoolExecutor(max_workers=10))
+
+        self.server.add_insecure_port("127.0.0.1:4317")
+
+        self.server.start()
+        # The below tests use the SpanExporter and Spans, but are testing the
+        # underlying behavior in the mixin. A MetricExporter or LogExporter could
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Good idea, done
created_at: 2025-03-31T14:30:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,147 +231,173 @@ def test_environ_to_compression(self):
             with self.assertRaises(InvalidCompressionValueException):
                 environ_to_compression("test_invalid")
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    def test_export_warning(self, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0]})
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {})
+    def test_otlp_exporter_otlp_compression_unspecified(
+        self, mock_insecure_channel
+    ):
+        """No env or kwarg should be NoCompression"""
+        OTLPSpanExporter(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.NoCompression
+        )
 
-        rpc_error = RpcError()
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
+    def test_otlp_exporter_otlp_compression_envvar(
+        self, mock_insecure_channel
+    ):
+        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
+        OTLPSpanExporter(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.Gzip
+        )
 
-        def code(self):
-            return None
+    def test_shutdown(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
+        self.exporter.shutdown()
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
+            )
+            self.assertEqual(
+                warning.records[0].message,
+                "Exporter already shutdown, ignoring batch",
+            )
 
-        rpc_error.code = MethodType(code, rpc_error)
+    def test_shutdown_wait_last_export(self):
+        # Shutdown waits 30 seconds for a pending Export call to finish.
+        # A 5 second delay is added, so it's expected that export will finish.
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK, 5),
+            self.server,
+        )
 
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = Mock()
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
-            )
+        export_thread = ThreadWithReturnValue(
+            target=self.exporter.export, args=([self.span],)
+        )
+        export_thread.start()
+        # Wait a sec for exporter to get lock and make export call.
+        time.sleep(1)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._export_lock.locked())
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Because the server is setup to wait 5 seconds before responding. That is within the export lock block.
created_at: 2025-03-31T14:32:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,147 +281,171 @@ def test_environ_to_compression(self):
             with self.assertRaises(InvalidCompressionValueException):
                 environ_to_compression("test_invalid")
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    def test_export_warning(self, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0]})
-
-        rpc_error = RpcError()
-
-        def code(self):
-            return None
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {})
+    def test_otlp_exporter_otlp_compression_unspecified(
+        self, mock_insecure_channel
+    ):
+        """No env or kwarg should be NoCompression"""
+        OTLPSpanExporterForTesting(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.NoCompression
+        )
 
-        rpc_error.code = MethodType(code, rpc_error)
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
+    def test_otlp_exporter_otlp_compression_envvar(
+        self, mock_insecure_channel
+    ):
+        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
+        OTLPSpanExporterForTesting(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.Gzip
+        )
 
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = Mock()
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
+    def test_shutdown(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
+        self.exporter.shutdown()
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
+            )
+            self.assertEqual(
+                warning.records[0].message,
+                "Exporter already shutdown, ignoring batch",
             )
 
-            def _translate_data(
-                self, data: Sequence[SDKDataT]
-            ) -> ExportServiceRequestT:
-                pass
+    def test_shutdown_wait_last_export(self):
+        # Shutdown waits 30 seconds for a pending Export call to finish.
+        # A 5 second delay is added, so it's expected that export will finish.
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK, 5),
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: nit consider passing the args as named params, it's hard to tell what `5` is in this case.
created_at: 2025-03-31T15:11:51Z
author_association: MEMBER
diff_hunk: @@ -64,147 +231,173 @@ def test_environ_to_compression(self):
             with self.assertRaises(InvalidCompressionValueException):
                 environ_to_compression("test_invalid")
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    def test_export_warning(self, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0]})
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {})
+    def test_otlp_exporter_otlp_compression_unspecified(
+        self, mock_insecure_channel
+    ):
+        """No env or kwarg should be NoCompression"""
+        OTLPSpanExporter(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.NoCompression
+        )
 
-        rpc_error = RpcError()
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
+    def test_otlp_exporter_otlp_compression_envvar(
+        self, mock_insecure_channel
+    ):
+        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
+        OTLPSpanExporter(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.Gzip
+        )
 
-        def code(self):
-            return None
+    def test_shutdown(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
+        self.exporter.shutdown()
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
+            )
+            self.assertEqual(
+                warning.records[0].message,
+                "Exporter already shutdown, ignoring batch",
+            )
 
-        rpc_error.code = MethodType(code, rpc_error)
+    def test_shutdown_wait_last_export(self):
+        # Shutdown waits 30 seconds for a pending Export call to finish.
+        # A 5 second delay is added, so it's expected that export will finish.
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK, 5),
+            self.server,
+        )
 
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = Mock()
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
-            )
+        export_thread = ThreadWithReturnValue(
+            target=self.exporter.export, args=([self.span],)
+        )
+        export_thread.start()
+        # Wait a sec for exporter to get lock and make export call.
+        time.sleep(1)
+        # pylint: disable=protected-access
+        self.assertTrue(self.exporter._export_lock.locked())
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: If you can make these test work without sleeping, it would be awesome. The sleeping slows things down but also we've seen flakes with PyPy tests from this kind of thing. If you can't get that working, consider lowering the sleep times so the test runs faster.

`Barrier` or `Semaphore` might be handy
created_at: 2025-03-31T15:28:55Z
author_association: MEMBER
diff_hunk: @@ -64,147 +281,171 @@ def test_environ_to_compression(self):
             with self.assertRaises(InvalidCompressionValueException):
                 environ_to_compression("test_invalid")
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter._create_exp_backoff_generator"
-    )
-    def test_export_warning(self, mock_expo):
-        mock_expo.configure_mock(**{"return_value": [0]})
-
-        rpc_error = RpcError()
-
-        def code(self):
-            return None
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {})
+    def test_otlp_exporter_otlp_compression_unspecified(
+        self, mock_insecure_channel
+    ):
+        """No env or kwarg should be NoCompression"""
+        OTLPSpanExporterForTesting(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.NoCompression
+        )
 
-        rpc_error.code = MethodType(code, rpc_error)
+    # pylint: disable=no-self-use
+    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
+    @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_COMPRESSION: "gzip"})
+    def test_otlp_exporter_otlp_compression_envvar(
+        self, mock_insecure_channel
+    ):
+        """Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
+        OTLPSpanExporterForTesting(insecure=True)
+        mock_insecure_channel.assert_called_once_with(
+            "localhost:4317", compression=Compression.Gzip
+        )
 
-        class OTLPMockExporter(OTLPExporterMixin):
-            _result = Mock()
-            _stub = Mock(
-                **{"return_value": Mock(**{"Export.side_effect": rpc_error})}
+    def test_shutdown(self):
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK),
+            self.server,
+        )
+        self.assertEqual(
+            self.exporter.export([self.span]), SpanExportResult.SUCCESS
+        )
+        self.exporter.shutdown()
+        with self.assertLogs(level=WARNING) as warning:
+            self.assertEqual(
+                self.exporter.export([self.span]), SpanExportResult.FAILURE
+            )
+            self.assertEqual(
+                warning.records[0].message,
+                "Exporter already shutdown, ignoring batch",
             )
 
-            def _translate_data(
-                self, data: Sequence[SDKDataT]
-            ) -> ExportServiceRequestT:
-                pass
+    def test_shutdown_wait_last_export(self):
+        # Shutdown waits 30 seconds for a pending Export call to finish.
+        # A 5 second delay is added, so it's expected that export will finish.
+        add_TraceServiceServicer_to_server(
+            TraceServiceServicerWithExportParams(StatusCode.OK, 5),
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Ack done
created_at: 2025-04-01T14:52:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,238 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+# pylint: disable=no-member
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    # pylint: disable=unsubscriptable-object
+    """OTLP span exporter
+
+    Args:
+        endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
+        credentials: Credentials object for server authentication
+        headers: Headers to send when exporting
+        timeout: Backend request timeout in seconds
+        compression: gRPC compression method to use
+    """
+
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def __init__(self, insecure=None, endpoint=None):
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: You can leave this out since it just calls the parent constructor
created_at: 2025-04-02T18:54:21Z
author_association: MEMBER
diff_hunk: @@ -26,20 +26,238 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+# pylint: disable=no-member
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    # pylint: disable=unsubscriptable-object
+    """OTLP span exporter
+
+    Args:
+        endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
+        credentials: Credentials object for server authentication
+        headers: Headers to send when exporting
+        timeout: Backend request timeout in seconds
+        compression: gRPC compression method to use
+    """
+
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def __init__(self, insecure=None, endpoint=None):
+        super().__init__(
+            **{
+                "insecure": insecure,
+                "endpoint": endpoint,
+            }
+        )
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
+        OTLPExporterMixin.shutdown(self, timeout_millis)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry_millis:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry_millis)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        target=None,
+        args=(),
+    ):
+        super().__init__(None, target, None, args, None)
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: aabmass
body: does this work?
```suggestion
        super().__init__(target=target, args=args)
```
created_at: 2025-04-02T18:58:25Z
author_association: MEMBER
diff_hunk: @@ -26,20 +26,238 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+# pylint: disable=no-member
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    # pylint: disable=unsubscriptable-object
+    """OTLP span exporter
+
+    Args:
+        endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
+        credentials: Credentials object for server authentication
+        headers: Headers to send when exporting
+        timeout: Backend request timeout in seconds
+        compression: gRPC compression method to use
+    """
+
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def __init__(self, insecure=None, endpoint=None):
+        super().__init__(
+            **{
+                "insecure": insecure,
+                "endpoint": endpoint,
+            }
+        )
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
+        OTLPExporterMixin.shutdown(self, timeout_millis)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry_millis:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry_millis)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        target=None,
+        args=(),
+    ):
+        super().__init__(None, target, None, args, None)
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Nice, yes. 
created_at: 2025-04-02T20:58:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,238 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+# pylint: disable=no-member
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    # pylint: disable=unsubscriptable-object
+    """OTLP span exporter
+
+    Args:
+        endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
+        credentials: Credentials object for server authentication
+        headers: Headers to send when exporting
+        timeout: Backend request timeout in seconds
+        compression: gRPC compression method to use
+    """
+
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def __init__(self, insecure=None, endpoint=None):
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Nice didn't know about that. Removed
created_at: 2025-04-02T21:00:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,230 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+# pylint: disable=no-member
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    # pylint: disable=unsubscriptable-object
+    """OTLP span exporter
+
+    Args:
+        endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
+        credentials: Credentials object for server authentication
+        headers: Headers to send when exporting
+        timeout: Backend request timeout in seconds
+        compression: gRPC compression method to use
+    """
+
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
+        OTLPExporterMixin.shutdown(self, timeout_millis)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry_millis:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry_millis)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        target=None,
+        args=(),
+    ):
+        super().__init__(target=target, args=args)
+        self._return = None
+
+    def run(self):
+        try:
+            if self._target is not None:  # type: ignore
+                self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+        finally:
+            # Avoid a refcycle if the thread is running a function with
+            # an argument that has a member that points to the thread.
+            del self._target, self._args, self._kwargs  # type: ignore
+
+    def join(self, timeout: Optional[float] = None) -> Any:
+        super().join(timeout=timeout)
+        return self._return
 
 
 class TestOTLPExporterMixin(TestCase):
+    def setUp(self):
+        self.server = server(ThreadPoolExecutor(max_workers=10))
+
+        self.server.add_insecure_port("127.0.0.1:4317")
+
+        self.server.start()
+        self.exporter = OTLPSpanExporterForTesting(insecure=True)
+        self.span = _Span(
+            "a",
+            context=Mock(
+                **{
+                    "trace_state": {"a": "b", "c": "d"},
+                    "span_id": 10217189687419569865,
+                    "trace_id": 67545097771067222548457157018666467027,
+                }
+            ),
+        )
+
+    def tearDown(self):
+        self.server.stop(None)
+
+    # pylint: disable=no-self-use
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: xrmx
body: ```suggestion
```
created_at: 2025-04-03T15:38:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -142,8 +142,8 @@ def _translate_data(
     def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
         return self._export(spans)
 
-    def shutdown(self) -> None:
-        OTLPExporterMixin.shutdown(self)
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py
user: login: xrmx
body: Is this a fix?
created_at: 2025-04-03T15:39:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -142,8 +142,8 @@ def _translate_data(
     def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
         return self._export(spans)
 
-    def shutdown(self) -> None:
-        OTLPExporterMixin.shutdown(self)
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py
user: login: DylanRussell
body: Yeah let me remove this from the PR. I think we should do it but doesn't belong in this PR.
created_at: 2025-04-03T16:47:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,230 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+# pylint: disable=no-member
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    # pylint: disable=unsubscriptable-object
+    """OTLP span exporter
+
+    Args:
+        endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
+        credentials: Credentials object for server authentication
+        headers: Headers to send when exporting
+        timeout: Backend request timeout in seconds
+        compression: gRPC compression method to use
+    """
+
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    def shutdown(self, timeout_millis: float = 30_000) -> None:
+        OTLPExporterMixin.shutdown(self, timeout_millis)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
+    def Export(self, request, context):
+        if self.optional_export_sleep:
+            time.sleep(self.optional_export_sleep)
+        if self.optional_export_retry_millis:
+            context.send_initial_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo().SerializeToString(),
+                    ),
+                )
+            )
+            context.set_trailing_metadata(
+                (
+                    (
+                        "google.rpc.retryinfo-bin",
+                        RetryInfo(
+                            retry_delay=Duration(
+                                nanos=int(self.optional_export_retry_millis)
+                            )
+                        ).SerializeToString(),
+                    ),
+                )
+            )
+        context.set_code(self.export_result)
+
+        return ExportTraceServiceResponse()
+
+
+class ThreadWithReturnValue(threading.Thread):
+    def __init__(
+        self,
+        target=None,
+        args=(),
+    ):
+        super().__init__(target=target, args=args)
+        self._return = None
+
+    def run(self):
+        try:
+            if self._target is not None:  # type: ignore
+                self._return = self._target(*self._args, **self._kwargs)  # type: ignore
+        finally:
+            # Avoid a refcycle if the thread is running a function with
+            # an argument that has a member that points to the thread.
+            del self._target, self._args, self._kwargs  # type: ignore
+
+    def join(self, timeout: Optional[float] = None) -> Any:
+        super().join(timeout=timeout)
+        return self._return
 
 
 class TestOTLPExporterMixin(TestCase):
+    def setUp(self):
+        self.server = server(ThreadPoolExecutor(max_workers=10))
+
+        self.server.add_insecure_port("127.0.0.1:4317")
+
+        self.server.start()
+        self.exporter = OTLPSpanExporterForTesting(insecure=True)
+        self.span = _Span(
+            "a",
+            context=Mock(
+                **{
+                    "trace_state": {"a": "b", "c": "d"},
+                    "span_id": 10217189687419569865,
+                    "trace_id": 67545097771067222548457157018666467027,
+                }
+            ),
+        )
+
+    def tearDown(self):
+        self.server.stop(None)
+
+    # pylint: disable=no-self-use
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Done
created_at: 2025-04-03T16:50:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,213 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: jomcgi
body: nit: unnecessary? if the self use is recognized by pylint
```suggestion
    # pylint: disable=invalid-name,unused-argument
```
created_at: 2025-04-03T18:02:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,213 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: jomcgi
body: nit: We're importing `StatusCode` from `grpc` via `opentelemetry.exporter.otlp.proto.grpc.exporter` instead of directly.
```suggestion
from grpc import Compression, server, StatusCode
```


created_at: 2025-04-03T18:05:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,213 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: jomcgi
body: nit: typing
```suggestion
        export_result: StatusCode,
        optional_export_sleep: Optional[float] = None,
        optional_export_retry_millis: Optional[float] = None,
```
created_at: 2025-04-03T18:13:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,213 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Nice, done
created_at: 2025-04-03T20:44:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,213 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
+    ):
+        self.export_result = export_result
+        self.optional_export_sleep = optional_export_sleep
+        self.optional_export_retry_millis = optional_export_retry_millis
+
+    # pylint: disable=invalid-name,unused-argument,no-self-use
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Yeah you're right it's not needed. Removed
created_at: 2025-04-03T20:49:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,20 +26,213 @@
 from google.rpc.error_details_pb2 import (  # pylint: disable=no-name-in-module
     RetryInfo,
 )
-from grpc import Compression
+from grpc import Compression, server
 
-from opentelemetry.exporter.otlp.proto.grpc.exporter import (
-    ExportServiceRequestT,
+from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
+    encode_spans,
+)
+from opentelemetry.exporter.otlp.proto.grpc.exporter import (  # noqa: F401
     InvalidCompressionValueException,
     OTLPExporterMixin,
-    RpcError,
-    SDKDataT,
     StatusCode,
     environ_to_compression,
 )
+from opentelemetry.exporter.otlp.proto.grpc.version import __version__
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
+    ExportTraceServiceRequest,
+    ExportTraceServiceResponse,
+)
+from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
+    TraceServiceServicer,
+    TraceServiceStub,
+    add_TraceServiceServicer_to_server,
+)
+from opentelemetry.sdk.environment_variables import (
+    OTEL_EXPORTER_OTLP_COMPRESSION,
+)
+from opentelemetry.sdk.trace import ReadableSpan, _Span
+from opentelemetry.sdk.trace.export import (
+    SpanExporter,
+    SpanExportResult,
+)
+
+
+# The below tests use this test SpanExporter and Spans, but are testing the
+# underlying behavior in the mixin. A MetricExporter or LogExporter could
+# just as easily be used.
+class OTLPSpanExporterForTesting(
+    SpanExporter,
+    OTLPExporterMixin[
+        ReadableSpan, ExportTraceServiceRequest, SpanExportResult
+    ],
+):
+    _result = SpanExportResult
+    _stub = TraceServiceStub
+
+    def _translate_data(
+        self, data: Sequence[ReadableSpan]
+    ) -> ExportTraceServiceRequest:
+        return encode_spans(data)
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        return self._export(spans)
+
+    @property
+    def _exporting(self):
+        return "traces"
+
+
+class TraceServiceServicerWithExportParams(TraceServiceServicer):
+    def __init__(
+        self,
+        export_result,
+        optional_export_sleep=None,
+        optional_export_retry_millis=None,
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py
user: login: DylanRussell
body: Added
created_at: 2025-04-03T20:49:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,145 +226,6 @@ def test_env_variables_with_only_certificate(
 
         mock_logger_error.assert_not_called()
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter.ssl_channel_credentials"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter._stub"
-    )
-    # pylint: disable=unused-argument
-    def test_no_credentials_error(
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py
user: login: emdneto
body: no needed anymore then? 
created_at: 2025-04-03T21:22:40Z
author_association: MEMBER
diff_hunk: @@ -369,4 +369,4 @@ def _exporting(self) -> str:
         Returns a string that describes the overall exporter, to be used in
         warning messages.
         """
-        pass
+        pass
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: emdneto
body: tox -e precommit should fix precommit ci
created_at: 2025-04-03T21:23:38Z
author_association: MEMBER
diff_hunk: @@ -369,4 +369,4 @@ def _exporting(self) -> str:
         Returns a string that describes the overall exporter, to be used in
         warning messages.
         """
-        pass
+        pass
path: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py
user: login: DylanRussell
body: Done thanks
created_at: 2025-04-04T13:36:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -296,145 +226,6 @@ def test_env_variables_with_only_certificate(
 
         mock_logger_error.assert_not_called()
 
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc.exporter.ssl_channel_credentials"
-    )
-    @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.secure_channel")
-    @patch(
-        "opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter._stub"
-    )
-    # pylint: disable=unused-argument
-    def test_no_credentials_error(
path: exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py
user: login: DylanRussell
body: I think I thought this test was meaningless because there is no "error". But I've added it back now, just in the mixin test file
created_at: 2025-04-04T14:02:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: xrmx
created_at: 2025-03-26T16:20:27Z
body: @DylanRussell have you seen https://github.com/open-telemetry/opentelemetry-python/pull/4490?
author_association: CONTRIBUTOR
user: login: DylanRussell
created_at: 2025-03-26T16:56:36Z
body: Ah I had not seen that ! Seems like a couple of us noticed the same thing.

It sounds like the behavior that what we want is for `Shutdown()` to not interrupt any in progress export RPC, but also for it to prevent any additional retries and also we would like `Shutdown()` to interrupt the `sleep` call in `export` if possible.

I'm thinking we can use threading events (https://docs.python.org/3/library/threading.html#event-objects) to accomplish all these things. We use one event for `export` to communicate to `shutdown` that an RPC is in progress, and we use another event for `shutdown` to communicate to `export` that shutdown is happening.

Check out my latest commit and let me know what you think !
author_association: CONTRIBUTOR
user: login: DylanRussell
created_at: 2025-03-26T20:49:35Z
body: I reverted all changes to the exporter shutdown logic from this PR. So this PR only touches tests now, and is just about deleting duplicate tests and fixing the existing broken shutdown test.

I moved my proposed shutdown changes to a new pr: https://github.com/open-telemetry/opentelemetry-python/pull/4511
author_association: CONTRIBUTOR
user: login: DylanRussell
created_at: 2025-03-31T13:20:56Z
body: I copied the existing tests. The only tests I changed were the shutdown tests (`test_shutdown.*`) because they were flaky / broken
author_association: CONTRIBUTOR
user: login: xrmx
created_at: 2025-04-03T15:44:08Z
body: @jomcgi care to take a look at this too please?
author_association: CONTRIBUTOR
user: login: DylanRussell
created_at: 2025-04-04T14:56:44Z
body: I think I need someone to add the Skip Changelog label
author_association: CONTRIBUTOR