Handling OTLP Export Retries and Timeouts
Retry configuration looks like a detail and behaves like a policy: the same four numbers decide whether a thirty-second backend hiccup is invisible or costs five minutes of telemetry. This page covers what each number controls, how they interact, and which failures are worth retrying at all. It is a task article under backpressure, retries and delivery guarantees, part of the Python telemetry pipelines and delivery section.
Prerequisites
pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
"opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"
Implementation
Step 1 — Set the per-attempt timeout from the hop's real latency. A timeout is the longest a single attempt is allowed to occupy the exporter, so it should be comfortably above the normal round trip and well below the point where waiting stops being useful. Against a collector on the same node, a healthy export completes in single-digit milliseconds, and five seconds is already three orders of magnitude of headroom. Against a remote backend across the internet, ten to fifteen seconds is reasonable. Leaving it at a large default means a single stalled connection blocks the exporter for that entire period while the queue fills.
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
exporter = OTLPSpanExporter(
endpoint="http://localhost:4317",
insecure=True,
timeout=5, # seconds, per attempt
)
Step 2 — Bound the total retry time in the collector. The collector is where the serious retry policy belongs, because it has the richest controls and because a change there does not require redeploying anything. Three parameters interact: the initial wait, the ceiling on that wait, and the total elapsed time after which the batch is abandoned.
exporters:
otlp/vendor:
endpoint: ingest.vendor.example:443
timeout: 10s # per attempt
retry_on_failure:
enabled: true
initial_interval: 1s # first wait
max_interval: 30s # ceiling on the growth
max_elapsed_time: 120s # give up here, and move on
Step 3 — Understand what the deadline trades. Extending max_elapsed_time does not reduce total loss; it changes which records are lost. A short deadline abandons batches quickly and keeps attempting newer ones, so the data that survives an outage is a thin sample spread across it. A long deadline keeps working on the oldest batch while everything newer piles up behind and is discarded at the queue, so the data that survives is a complete record of the outage's first moments and nothing after. For diagnosing an incident, the thin sample is almost always more useful, because it shows what happened throughout rather than what happened before anyone noticed.
Step 4 — Do not retry what cannot succeed. Retry is worthwhile only for conditions that can resolve without the payload changing: the endpoint being unavailable, a timeout, a rate limit, a transient network failure. A payload rejected as malformed, too large, or unauthorised will be rejected identically every time, and retrying it wastes the exporter for the full deadline while valid data queues behind it. Both the SDK and the collector distinguish these, but a custom exporter or a proxy in the path can blur the distinction, which is worth checking when retries are high and the backend reports nothing wrong.
Step 5 — Add jitter, or add it by accident. When a backend recovers from an outage, every client whose backoff expired at the same moment attempts simultaneously, which can push it straight back down. The collector's backoff includes randomisation; a hand-rolled retry loop usually does not. If you are writing one — and mostly you should not be — the randomisation is not optional.
Expected Output: an outage as it appears in the collector's own log, with the backoff visible and the abandonment explicit.
2026-09-18T13:02:11Z warn exporterhelper Exporting failed. Will retry. {"error": "rpc error: code = Unavailable", "interval": "1s"}
2026-09-18T13:02:14Z warn exporterhelper Exporting failed. Will retry. {"error": "rpc error: code = Unavailable", "interval": "2.4s"}
2026-09-18T13:02:21Z warn exporterhelper Exporting failed. Will retry. {"error": "rpc error: code = Unavailable", "interval": "4.8s"}
2026-09-18T13:04:11Z error exporterhelper Dropping data because sending_queue is full or retry deadline exceeded {"dropped_items": 2048}
Choosing the four numbers
The parameters are not independent, and a useful way to choose them is to start from the outage you intend to absorb and work backwards.
Suppose the next hop is a gateway that takes twenty seconds to roll out a new version, and the service produces four hundred spans per second. The queue must hold twenty seconds of production — eight thousand spans — or data is lost regardless of retry settings. The retry deadline must exceed the rollout, so sixty seconds is comfortable and one hundred and twenty is generous. The per-attempt timeout should be short, because during a rollout the connection fails fast rather than hanging, so five seconds only ever applies to the pathological case.
Now change one assumption: the next hop is an external vendor with occasional multi-minute degradations. The queue cannot hold five minutes of production without becoming expensive, so some loss is certain. The question becomes which loss, and the answer from the previous section is to keep the deadline short — sixty seconds — so the pipeline keeps sampling across the outage rather than perfecting its first batch.
A third case makes the trade explicit. A records pipeline carrying billing events, where loss is unacceptable, cannot solve this with retry parameters at all: it needs a persistent queue, so that the deadline governs how long records wait on disk rather than how many are discarded from memory. Recognising which case you are in is more valuable than any specific interval, because the first two are tuning and the third is a different design.
One further interaction is easy to miss. The per-attempt timeout and the batch schedule delay together determine how far behind the exporter can fall before the queue is the binding constraint. If a batch is produced every five seconds and each attempt takes up to ten, the exporter is structurally unable to keep up during any period of slowness, and the queue drains only once the backend is healthy. Keeping the attempt timeout below the batch interval avoids that regime entirely.
Configuration options
| Parameter | Local hop | Remote hop | Notes |
|---|---|---|---|
per-attempt timeout |
5 s | 10–15 s | below the batch interval where possible |
initial_interval |
1 s | 1 s | first wait after a failure |
max_interval |
30 s | 30 s | ceiling on backoff growth |
max_elapsed_time |
60–120 s | 60 s | shorter keeps sampling across an outage |
| jitter | built in | built in | do not remove it in custom code |
| retry on 4xx | never | never | identical bytes, identical verdict |
| queue size | outage × rate | outage × rate | the real determinant of loss |
Verification
Reproduce an outage deliberately and observe both the backoff and the abandonment, because a configuration that has never failed has never been tested.
# stop the next hop for 90 seconds, then bring it back
kubectl scale deploy/otel-gateway --replicas=0
sleep 90 && kubectl scale deploy/otel-gateway --replicas=3
# watch the agent's counters through the window
watch -n5 "curl -s localhost:8888/metrics | grep -E \
'otelcol_exporter_(send_failed_spans|queue_size|sent_spans)'"
Expected Output: failures rise, the queue fills partway, and recovery drains it without a restart.
otelcol_exporter_send_failed_spans 2048
otelcol_exporter_queue_size 1841
otelcol_exporter_sent_spans 184203 # climbing again after recovery
A queue that reaches its maximum and stays there after recovery means the exporter is not draining faster than production, which is a throughput problem rather than a retry problem and needs a different fix.
Common mistakes
A large default timeout left in place. Error signature: one stalled export blocking all telemetry for a minute. Root cause: the per-attempt timeout is longer than the period in which the queue fills. Remediation: set it explicitly, matched to the hop, as in step 1.
Raising the deadline after an incident. Error signature: the next outage produces complete data for its first minute and nothing afterwards. Root cause: treating retry duration as a reliability control. Remediation: shorten it; increase the queue instead if the goal is to lose less.
Retrying permanent rejections. Error signature: high retry counts with a backend reporting no errors. Root cause: a malformed or oversized payload, or an expired credential, retried to the deadline. Remediation: confirm the status code being retried and stop retrying the permanent classes.
No jitter in a custom retry loop. Error signature: a backend that recovers and immediately fails again. Root cause: every client retrying at the same instant. Remediation: use the SDK or collector's retry rather than a hand-written loop, and randomise if you must write one.
Retrying in two places at once. Error signature: an outage that produces far more attempts than either configuration would explain. Root cause: the application's SDK retries into an agent that is itself retrying outward, so the attempt counts multiply rather than add. Remediation: keep the application's retry minimal because its hop is local and reliable, and let the collector own the policy against the unreliable hop.
Alerting on retries. Error signature: an alert that fires several times a week and is always ignored. Root cause: retries are normal. Remediation: alert on batches abandoned after the deadline, which is the number that means data was actually lost.
Frequently Asked Questions
What is the difference between the exporter timeout and the retry deadline?
The timeout bounds a single attempt; the deadline bounds the whole sequence of attempts for one batch. A five second timeout with a two minute deadline means up to roughly a dozen attempts, each given five seconds, before the batch is abandoned.
Should a 400-class response be retried?
No. A rejection because the payload is malformed, too large or unauthorised will be rejected identically on every attempt, so retrying wastes the exporter's time and delays everything behind it. Only conditions that can change on their own — unavailable, timeout, resource exhausted — are worth retrying.
Why does my collector report retries but the backend looks healthy?
Usually rate limiting. A backend that returns a resource-exhausted status when a client exceeds its quota is healthy and is telling you to slow down. The fix is less volume or a higher quota, not more aggressive retrying.
Does the Python SDK retry by default?
The OTLP exporters implement retry with exponential backoff internally, bounded by an overall deadline. Its parameters are less configurable than the collector's, which is one more reason to keep the application's hop local and short and to do the serious retrying one tier out.