Skip to content

Commit 0307772

Browse files
[Monitor OpenTelemetry Exporter] Use Latest Retry-After Time (#37722)
### Packages impacted by this PR @azure/monitor-opentelemetry-exporter ### Describe the problem that is addressed by this PR This pull request addresses a bug in the retry logic for throttled telemetry in the OpenTelemetry exporter. The main improvement ensures that when multiple `Retry-After` headers are received, the exporter uses the longest delay, preventing premature retries and improving envelope delivery reliability. Bug Fixes: * Updated the retry timer logic in `baseSender.ts` to reschedule when a new `Retry-After` value is longer than the existing timer, ensuring envelopes are sent at the latest required time. * Modified the corresponding unit test in `baseSender.spec.ts` to verify that the retry timer is rescheduled for longer delays, aligning test behavior with the new logic. * Added a changelog entry documenting the bug fix regarding multiple `Retry-After` headers and the use of the longest delay. ### Checklists - [x] Added impacted package name to the issue description - [ ] Does this PR needs any fixes in the SDK Generator?** _(If so, create an Issue in the [Autorest/typescript](https://github.com/Azure/autorest.typescript) repository and link it here)_ - [x] Added a changelog (if necessary) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 79063c3 commit 0307772

3 files changed

Lines changed: 25 additions & 11 deletions

File tree

sdk/monitor/monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
- Throttled telemetry (429 responses) is now persisted to disk for retry instead of being silently dropped.
1313
- Specific GenAI properties are now truncated to 256KB instead of being exempt from truncation limits.
1414

15+
### Bugs Fixed
16+
17+
- When multiple `Retry-After` headers are received, the exporter now compares absolute deadlines to ensure envelopes are sent at the latest required time.
18+
1519
## 1.0.0-beta.39 (2026-02-20)
1620

1721
### Features Added

sdk/monitor/monitor-opentelemetry-exporter/src/platform/nodejs/baseSender.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export abstract class BaseSender {
3838
private readonly persister: PersistentStorage;
3939
private numConsecutiveRedirects: number;
4040
private retryTimer: NodeJS.Timeout | null;
41-
private retryTimerDelayMs: number = 0;
41+
private retryTimerDeadlineMs: number = 0;
4242
private networkStatsbeatMetrics: NetworkStatsbeatMetrics | undefined;
4343
private customerSDKStatsMetrics: CustomerSDKStatsMetrics | undefined;
4444
private longIntervalStatsbeatMetrics;
@@ -399,17 +399,19 @@ export abstract class BaseSender {
399399

400400
private scheduleRetryTimer(retryAfterMs?: number): void {
401401
const delay = retryAfterMs ?? this.batchSendRetryIntervalMs;
402-
// Reschedule if a new Retry-After would fire sooner than the existing timer
403-
if (this.retryTimer && retryAfterMs !== undefined && delay < this.retryTimerDelayMs) {
402+
const newDeadline = Date.now() + delay;
403+
// Reschedule if a new Retry-After results in a later absolute deadline
404+
if (this.retryTimer && retryAfterMs !== undefined && newDeadline > this.retryTimerDeadlineMs) {
404405
clearTimeout(this.retryTimer);
405406
this.retryTimer = null;
406407
}
407408
if (!this.retryTimer) {
408-
this.retryTimerDelayMs = delay;
409+
const adjustedDelay = Math.max(newDeadline - Date.now(), 0);
410+
this.retryTimerDeadlineMs = newDeadline;
409411
this.retryTimer = setTimeout(() => {
410412
this.retryTimer = null;
411413
this.sendFirstPersistedFile();
412-
}, delay);
414+
}, adjustedDelay);
413415
this.retryTimer.unref();
414416
}
415417
}

sdk/monitor/monitor-opentelemetry-exporter/test/internal/baseSender.spec.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -706,33 +706,41 @@ describe("BaseSender", () => {
706706
setTimeoutSpy.mockRestore();
707707
});
708708

709-
it("should reschedule retry timer when new retryAfterMs is shorter", async () => {
709+
it("should reschedule retry timer when new retryAfterMs results in a later absolute deadline", async () => {
710+
vi.useFakeTimers();
710711
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
712+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
711713

712714
const { isRetriable } = await import("../../src/utils/breezeUtils.js");
713715
vi.mocked(isRetriable).mockImplementation(
714716
(statusCode) => statusCode === 429 || statusCode === 200,
715717
);
716718

717-
// First call with default timer (no retryAfterMs)
719+
// First call with a 30s retryAfterMs at T=0 → deadline = T+30s
718720
sender.sendMock.mockResolvedValue({
719721
statusCode: 200,
720722
result: "success",
723+
retryAfterMs: 30_000,
721724
});
722725
await sender.exportEnvelopes([{ name: "test", time: new Date() }]);
723726

724-
// Second call with a shorter retryAfterMs should reschedule
727+
// Advance 20s, then second call with 15s retryAfterMs → deadline = T+35s (later)
728+
vi.advanceTimersByTime(20_000);
725729
sender.sendMock.mockResolvedValue({
726730
statusCode: 200,
727731
result: "success",
728-
retryAfterMs: 5_000,
732+
retryAfterMs: 15_000,
729733
});
730734
await sender.exportEnvelopes([{ name: "test2", time: new Date() }]);
731735

732-
// Verify setTimeout was called with the shorter delay
733-
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5_000);
736+
// clearTimeout should have been called to reschedule
737+
expect(clearTimeoutSpy).toHaveBeenCalled();
738+
// The rescheduled timer should use the adjusted delay (~15s)
739+
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 15_000);
734740

741+
clearTimeoutSpy.mockRestore();
735742
setTimeoutSpy.mockRestore();
743+
vi.useRealTimers();
736744
});
737745
});
738746

0 commit comments

Comments
 (0)