From 8a57655af7d8e9e1034ab7c6c3a3f90c51c5249f Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Sat, 1 Aug 2026 18:59:53 +0000 Subject: [PATCH 1/4] Add reproduction for OtlpExporter issue --- .../OtlpExporterRetryAfterDate.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts diff --git a/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts b/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts new file mode 100644 index 00000000000..86c984a3d7e --- /dev/null +++ b/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest" +import { Effect, Ref } from "effect" +import { TestClock } from "effect/testing" +import { HttpBody, HttpClient, HttpClientResponse } from "effect/unstable/http" +import { OtlpExporter } from "effect/unstable/observability" + +it.effect("honors an HTTP-date Retry-After value", () => + Effect.scoped(Effect.gen(function*() { + const attempts = yield* Ref.make(0) + const client = HttpClient.make((request) => + Ref.updateAndGet(attempts, (attempt) => attempt + 1).pipe( + Effect.map((attempt) => + HttpClientResponse.fromWeb( + request, + attempt === 1 + ? new Response(null, { + status: 429, + headers: { "retry-after": "Thu, 01 Jan 1970 00:01:00 GMT" } + }) + : new Response() + ) + ) + )) + const exporter = yield* OtlpExporter.make({ + url: "http://localhost/v1/logs", + headers: undefined, + label: "repro", + exportInterval: "1 hour", + maxBatchSize: 1, + body: () => HttpBody.empty, + shutdownTimeout: "1 second" + }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.provide(OtlpExporter.layerFlusher) + ) + + exporter.push(1) + yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) + assert.strictEqual(yield* Ref.get(attempts), 1) + + yield* TestClock.adjust("5 seconds") + yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) + assert.strictEqual(yield* Ref.get(attempts), 1) + }))) From 13861575dc50e48bb17b505273e882f16b448fae Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 3 Aug 2026 10:46:17 +1200 Subject: [PATCH 2/4] Fix OTLP Retry-After HTTP-date handling --- .../unstable/observability/OtlpExporter.ts | 48 ++++++++++++------- .../OtlpExporterRetryAfterDate.test.ts | 7 ++- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/effect/src/unstable/observability/OtlpExporter.ts b/packages/effect/src/unstable/observability/OtlpExporter.ts index c02f13177ee..b127cc07ee1 100644 --- a/packages/effect/src/unstable/observability/OtlpExporter.ts +++ b/packages/effect/src/unstable/observability/OtlpExporter.ts @@ -26,23 +26,35 @@ import * as HttpClientError from "../../unstable/http/HttpClientError.ts" import * as HttpClientRequest from "../../unstable/http/HttpClientRequest.ts" import type { HttpBody } from "../http/HttpBody.ts" -const policy = Schedule.forever.pipe( - Schedule.passthrough, - Schedule.addDelay(({ output: error }) => { - if ( - HttpClientError.isHttpClientError(error) - && error.reason._tag === "StatusCodeError" - && error.reason.response.status === 429 - ) { - const retryAfter = Option.fromUndefinedOr(error.reason.response.headers["retry-after"]).pipe( - Option.flatMap(Num.parse), - Option.getOrElse(() => 5) - ) - return Effect.succeed(Duration.seconds(retryAfter)) - } - return Effect.succeed(Duration.seconds(1)) - }) -) +const retryAfterDelay = (clock: Clock, value: string | undefined): Duration.Duration => { + const seconds = Option.fromUndefinedOr(value).pipe(Option.flatMap(Num.parse)) + if (Option.isSome(seconds)) { + return Duration.seconds(seconds.value) + } + if (value === undefined) { + return Duration.seconds(5) + } + const timestamp = Date.parse(value) + if (Number.isNaN(timestamp)) { + return Duration.seconds(5) + } + return Duration.millis(Math.max(timestamp - clock.currentTimeMillisUnsafe(), 1)) +} + +const policy = (clock: Clock) => + Schedule.forever.pipe( + Schedule.passthrough, + Schedule.addDelay(({ output: error }) => { + if ( + HttpClientError.isHttpClientError(error) + && error.reason._tag === "StatusCodeError" + && error.reason.response.status === 429 + ) { + return Effect.succeed(retryAfterDelay(clock, error.reason.response.headers["retry-after"])) + } + return Effect.succeed(Duration.seconds(1)) + }) + ) /** * Registry of exporter flush operations, used to manually drain buffered @@ -169,7 +181,7 @@ export const make: ( const client = HttpClient.filterStatusOk(Context.get(services, HttpClient.HttpClient)).pipe( HttpClient.transformResponse(Effect.provideService(HttpClient.TracerPropagationEnabled, false)), - HttpClient.retryTransient({ schedule: policy, times: 3 }) + HttpClient.retryTransient({ schedule: policy(clock), times: 3 }) ) let headers = Headers.fromRecordUnsafe({ diff --git a/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts b/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts index 86c984a3d7e..958f66e8f5c 100644 --- a/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts +++ b/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts @@ -20,7 +20,8 @@ it.effect("honors an HTTP-date Retry-After value", () => : new Response() ) ) - )) + ) + ) const exporter = yield* OtlpExporter.make({ url: "http://localhost/v1/logs", headers: undefined, @@ -41,4 +42,8 @@ it.effect("honors an HTTP-date Retry-After value", () => yield* TestClock.adjust("5 seconds") yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) assert.strictEqual(yield* Ref.get(attempts), 1) + + yield* TestClock.adjust("55 seconds") + yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) + assert.strictEqual(yield* Ref.get(attempts), 2) }))) From 0f83802bd34f32cb9f8ba4c8f3d7f5c6aae7f3c5 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 3 Aug 2026 10:59:54 +1200 Subject: [PATCH 3/4] Address OtlpExporter review feedback --- .changeset/quiet-otters-retry.md | 5 ++ .../observability/OtlpExporter.test.ts | 21 ++++++++ .../OtlpExporterRetryAfterDate.test.ts | 49 ------------------- 3 files changed, 26 insertions(+), 49 deletions(-) create mode 100644 .changeset/quiet-otters-retry.md delete mode 100644 packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts diff --git a/.changeset/quiet-otters-retry.md b/.changeset/quiet-otters-retry.md new file mode 100644 index 00000000000..7673c470252 --- /dev/null +++ b/.changeset/quiet-otters-retry.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Honor HTTP-date `Retry-After` values when retrying OTLP exports. diff --git a/packages/effect/test/unstable/observability/OtlpExporter.test.ts b/packages/effect/test/unstable/observability/OtlpExporter.test.ts index 8635d926e86..d3d933a3dbb 100644 --- a/packages/effect/test/unstable/observability/OtlpExporter.test.ts +++ b/packages/effect/test/unstable/observability/OtlpExporter.test.ts @@ -270,6 +270,27 @@ describe("OtlpExporter", () => { }) )) + it.effect("retries status 429 with HTTP-date retry-after delay", () => + Effect.scoped( + Effect.gen(function*() { + const { attempts, httpClient } = yield* makeHttpClient("Thu, 01 Jan 1970 00:01:00 GMT") + const exporter = yield* makeExporter(httpClient) + + exporter.push({ value: 1 }) + yield* yieldNowN(3) + + assert.strictEqual(yield* Ref.get(attempts), 1) + + yield* TestClock.adjust("5 seconds") + yield* yieldNowN(2) + assert.strictEqual(yield* Ref.get(attempts), 1) + + yield* TestClock.adjust("55 seconds") + yield* yieldNowN(2) + assert.strictEqual(yield* Ref.get(attempts), 2) + }) + )) + it.effect("uses fallback retry-after delay when header is non-numeric", () => Effect.scoped( Effect.gen(function*() { diff --git a/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts b/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts deleted file mode 100644 index 958f66e8f5c..00000000000 --- a/packages/effect/test/unstable/observability/OtlpExporterRetryAfterDate.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { assert, it } from "@effect/vitest" -import { Effect, Ref } from "effect" -import { TestClock } from "effect/testing" -import { HttpBody, HttpClient, HttpClientResponse } from "effect/unstable/http" -import { OtlpExporter } from "effect/unstable/observability" - -it.effect("honors an HTTP-date Retry-After value", () => - Effect.scoped(Effect.gen(function*() { - const attempts = yield* Ref.make(0) - const client = HttpClient.make((request) => - Ref.updateAndGet(attempts, (attempt) => attempt + 1).pipe( - Effect.map((attempt) => - HttpClientResponse.fromWeb( - request, - attempt === 1 - ? new Response(null, { - status: 429, - headers: { "retry-after": "Thu, 01 Jan 1970 00:01:00 GMT" } - }) - : new Response() - ) - ) - ) - ) - const exporter = yield* OtlpExporter.make({ - url: "http://localhost/v1/logs", - headers: undefined, - label: "repro", - exportInterval: "1 hour", - maxBatchSize: 1, - body: () => HttpBody.empty, - shutdownTimeout: "1 second" - }).pipe( - Effect.provideService(HttpClient.HttpClient, client), - Effect.provide(OtlpExporter.layerFlusher) - ) - - exporter.push(1) - yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) - assert.strictEqual(yield* Ref.get(attempts), 1) - - yield* TestClock.adjust("5 seconds") - yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) - assert.strictEqual(yield* Ref.get(attempts), 1) - - yield* TestClock.adjust("55 seconds") - yield* Effect.forEach(Array.from({ length: 3 }), () => Effect.yieldNow, { discard: true }) - assert.strictEqual(yield* Ref.get(attempts), 2) - }))) From be1a27dbfb9f4428918b1e35bc2fff48cd74ac2c Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 3 Aug 2026 11:10:17 +1200 Subject: [PATCH 4/4] Use Clock reference in OTLP retry policy --- .../unstable/observability/OtlpExporter.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/effect/src/unstable/observability/OtlpExporter.ts b/packages/effect/src/unstable/observability/OtlpExporter.ts index b127cc07ee1..84cc1184425 100644 --- a/packages/effect/src/unstable/observability/OtlpExporter.ts +++ b/packages/effect/src/unstable/observability/OtlpExporter.ts @@ -26,35 +26,37 @@ import * as HttpClientError from "../../unstable/http/HttpClientError.ts" import * as HttpClientRequest from "../../unstable/http/HttpClientRequest.ts" import type { HttpBody } from "../http/HttpBody.ts" -const retryAfterDelay = (clock: Clock, value: string | undefined): Duration.Duration => { +const retryAfterDelay = (value: string | undefined): Effect.Effect => { const seconds = Option.fromUndefinedOr(value).pipe(Option.flatMap(Num.parse)) if (Option.isSome(seconds)) { - return Duration.seconds(seconds.value) + return Effect.succeed(Duration.seconds(seconds.value)) } if (value === undefined) { - return Duration.seconds(5) + return Effect.succeed(Duration.seconds(5)) } const timestamp = Date.parse(value) if (Number.isNaN(timestamp)) { - return Duration.seconds(5) + return Effect.succeed(Duration.seconds(5)) } - return Duration.millis(Math.max(timestamp - clock.currentTimeMillisUnsafe(), 1)) + return Effect.map( + Clock, + (clock) => Duration.millis(Math.max(timestamp - clock.currentTimeMillisUnsafe(), 1)) + ) } -const policy = (clock: Clock) => - Schedule.forever.pipe( - Schedule.passthrough, - Schedule.addDelay(({ output: error }) => { - if ( - HttpClientError.isHttpClientError(error) - && error.reason._tag === "StatusCodeError" - && error.reason.response.status === 429 - ) { - return Effect.succeed(retryAfterDelay(clock, error.reason.response.headers["retry-after"])) - } - return Effect.succeed(Duration.seconds(1)) - }) - ) +const policy = Schedule.forever.pipe( + Schedule.passthrough, + Schedule.addDelay(({ output: error }) => { + if ( + HttpClientError.isHttpClientError(error) + && error.reason._tag === "StatusCodeError" + && error.reason.response.status === 429 + ) { + return retryAfterDelay(error.reason.response.headers["retry-after"]) + } + return Effect.succeed(Duration.seconds(1)) + }) +) /** * Registry of exporter flush operations, used to manually drain buffered @@ -181,7 +183,7 @@ export const make: ( const client = HttpClient.filterStatusOk(Context.get(services, HttpClient.HttpClient)).pipe( HttpClient.transformResponse(Effect.provideService(HttpClient.TracerPropagationEnabled, false)), - HttpClient.retryTransient({ schedule: policy(clock), times: 3 }) + HttpClient.retryTransient({ schedule: policy, times: 3 }) ) let headers = Headers.fromRecordUnsafe({