diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
index 854a47bf..17ddfd7e 100644
--- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
+++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json
@@ -65,7 +65,7 @@
{
"id": "B3.1",
"subject": "B3: ConnectionBackoff primitive in Core.Abstractions (static ComputeDelay ported from S7 + instance attempt-throttle) + ConnectionBackoffTests \u2014 STAB-8 seam, hand-off to R2-01",
- "status": "pending",
+ "status": "completed",
"blockedBy": []
},
{
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs
new file mode 100644
index 00000000..95022c48
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs
@@ -0,0 +1,93 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+///
+/// Shared capped-exponential backoff for poll-based / lazy-reconnect drivers, extracted from
+/// the S7 driver's bespoke poll fork (05/STAB-8, CONV-1). Provides two surfaces:
+///
+/// - — the pure schedule (base, 2×, 4×, … saturating at a
+/// cap) used by PollGroupEngine to stretch its retry cadence under sustained
+/// failure.
+/// - The instance API ( / /
+/// ) — a per-device connect-attempt throttle so a dead device
+/// is not hammered with a full reconnect on every data call / poll tick.
+///
+///
+///
+///
+/// Not internally synchronized. The instance methods mutate plain fields; every
+/// intended call site already serializes on its per-device gate (the driver's
+/// ConnectGate / connect semaphore / probe lock), so adding lock-free interlocked
+/// state would be needless complexity. Callers MUST invoke the instance under that gate.
+///
+///
+/// is pure + thread-safe and may be called from anywhere.
+///
+///
+public sealed class ConnectionBackoff
+{
+ private readonly TimeSpan _baseDelay;
+ private readonly TimeSpan _maxDelay;
+ private int _consecutiveFailures;
+ private DateTime _nextAttemptUtc = DateTime.MinValue;
+
+ /// Initializes a new per-device attempt throttle.
+ /// The initial backoff window after the first failure.
+ /// The upper bound the exponential growth saturates at.
+ public ConnectionBackoff(TimeSpan baseDelay, TimeSpan maxDelay)
+ {
+ _baseDelay = baseDelay;
+ _maxDelay = maxDelay;
+ }
+
+ ///
+ /// Capped exponential backoff schedule. ≤ 0 returns
+ /// ; each subsequent failure doubles the wait up to
+ /// . Computed in ticks with an overflow guard so a large failure
+ /// count saturates at the cap rather than wrapping negative. Ported verbatim from the S7
+ /// poll fork's ComputeBackoffDelay.
+ ///
+ /// The base interval (returned when there are no failures).
+ /// The number of consecutive failures observed.
+ /// The maximum delay the schedule saturates at.
+ /// The computed backoff delay.
+ public static TimeSpan ComputeDelay(TimeSpan baseInterval, int consecutiveFailures, TimeSpan cap)
+ {
+ if (consecutiveFailures <= 0) return baseInterval;
+ // Cap the shift to avoid overflow — at 30 the result already saturates the cap for any
+ // reasonable base interval.
+ var shift = Math.Min(consecutiveFailures - 1, 30);
+ var ticks = baseInterval.Ticks << shift;
+ if (ticks <= 0 || ticks > cap.Ticks) return cap;
+ return TimeSpan.FromTicks(ticks);
+ }
+
+ ///
+ /// Returns true when is at or past the end of the current
+ /// backoff window (i.e. a connect / create attempt is due). A fresh instance and a
+ /// just-reset instance always return true.
+ ///
+ /// The current UTC time.
+ /// true when an attempt is permitted.
+ public bool ShouldAttempt(DateTime nowUtc) => nowUtc >= _nextAttemptUtc;
+
+ ///
+ /// Records a failed attempt: increments the consecutive-failure count and opens a backoff
+ /// window ending from .
+ ///
+ /// The current UTC time (the window is measured from here).
+ public void RecordFailure(DateTime nowUtc)
+ {
+ _consecutiveFailures++;
+ _nextAttemptUtc = nowUtc + ComputeDelay(_baseDelay, _consecutiveFailures, _maxDelay);
+ }
+
+ ///
+ /// Records a successful attempt: resets the failure count and clears the window so the next
+ /// attempt is permitted immediately. Recovery is never delayed by a residual backoff window.
+ ///
+ public void RecordSuccess()
+ {
+ _consecutiveFailures = 0;
+ _nextAttemptUtc = DateTime.MinValue;
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs
new file mode 100644
index 00000000..3af88cf0
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs
@@ -0,0 +1,98 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
+
+///
+/// Covers — the shared capped-exponential backoff extracted
+/// from the S7 poll fork (05/STAB-8; the seam plan R2-01 wires into the S7 connect throttle).
+/// Two surfaces: the static schedule and the
+/// per-device attempt-throttle instance.
+///
+[Trait("Category", "Unit")]
+public sealed class ConnectionBackoffTests
+{
+ private static readonly TimeSpan Base = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan Cap = TimeSpan.FromSeconds(30);
+
+ /// Zero (or negative) consecutive failures returns the base interval unchanged.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void ComputeDelay_NoFailures_ReturnsBaseInterval(int failures)
+ => ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Base);
+
+ /// The delay doubles per consecutive failure (1×, 2×, 4×, 8×) until it saturates the cap.
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 2)]
+ [InlineData(3, 4)]
+ [InlineData(4, 8)]
+ [InlineData(5, 16)]
+ public void ComputeDelay_DoublesPerFailure(int failures, int expectedSeconds)
+ => ConnectionBackoff.ComputeDelay(Base, failures, Cap)
+ .ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
+
+ /// Growth saturates at the cap and never exceeds it, even at a large failure count (overflow guard).
+ [Theory]
+ [InlineData(6)] // 32s would exceed 30s cap
+ [InlineData(30)]
+ [InlineData(1000)] // shift saturates; ticks overflow guard returns cap
+ public void ComputeDelay_SaturatesAtCap(int failures)
+ => ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Cap);
+
+ /// A fresh throttle permits the first attempt immediately.
+ [Fact]
+ public void ShouldAttempt_FreshInstance_AllowsImmediately()
+ {
+ var backoff = new ConnectionBackoff(Base, Cap);
+ backoff.ShouldAttempt(DateTime.UtcNow).ShouldBeTrue();
+ }
+
+ /// After a failure the throttle blocks inside the backoff window and reopens once it elapses.
+ [Fact]
+ public void RecordFailure_BlocksWithinWindow_ReopensAfter()
+ {
+ var backoff = new ConnectionBackoff(Base, Cap);
+ var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ backoff.RecordFailure(t0); // window = 1s (first failure)
+ backoff.ShouldAttempt(t0).ShouldBeFalse(); // still inside window
+ backoff.ShouldAttempt(t0.AddMilliseconds(500)).ShouldBeFalse();
+ backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue(); // window elapsed
+ }
+
+ /// Consecutive failures widen the window (1s then 2s).
+ [Fact]
+ public void RecordFailure_ConsecutiveFailures_WidenWindow()
+ {
+ var backoff = new ConnectionBackoff(Base, Cap);
+ var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ backoff.RecordFailure(t0); // 1s
+ backoff.RecordFailure(t0.AddSeconds(1)); // 2nd failure → 2s window
+ backoff.ShouldAttempt(t0.AddSeconds(2)).ShouldBeFalse(); // within the 2s window
+ backoff.ShouldAttempt(t0.AddSeconds(3)).ShouldBeTrue();
+ }
+
+ /// Success resets immediately — recovery is never delayed by a residual window.
+ [Fact]
+ public void RecordSuccess_ResetsWindowImmediately()
+ {
+ var backoff = new ConnectionBackoff(Base, Cap);
+ var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ backoff.RecordFailure(t0);
+ backoff.RecordFailure(t0); // deep in a widened window
+ backoff.ShouldAttempt(t0).ShouldBeFalse();
+
+ backoff.RecordSuccess();
+ backoff.ShouldAttempt(t0).ShouldBeTrue(); // reset, no residual delay
+
+ // And the schedule restarts from the base window on the next failure.
+ backoff.RecordFailure(t0);
+ backoff.ShouldAttempt(t0.AddMilliseconds(999)).ShouldBeFalse();
+ backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue();
+ }
+}