Skip to content

Commit f9ffb87

Browse files
refactor(audience): refactor HttpTransport (SDK-141)
Addresses nattb8's review comment asking for nullability clarity: - Adopt Passport's per-file `#nullable enable` pattern. BuildPayload's null return and optional ctor dependencies (onError, handler, getUtcNow) are now expressed in the type system, not only in docstrings. - Rewrite the file's comments as direct, plain-English facts. Class summary trimmed to a one-line role description; per-outcome behaviour stays inline at each branch where it can't drift from the code. Ctor docs describe each parameter's purpose rather than one caller's use of it. - Fix the cancellation catch comment that incorrectly attributed token tripping to HttpTransport.Dispose (it's the caller's responsibility). - Handle non-IOException storage failures in SendBatchAsync. BuildPayload narrows its catch to IOException (transient disk races); everything else (e.g. UnauthorizedAccessException from stripped permissions) was escaping SendBatchAsync entirely and would silently kill the flush loop once a scheduler is wired up. Wrap the BuildPayload call: on any non-IOException failure, delete the batch, report via onError with FlushFailed, return true. Package-wide <Nullable>enable</Nullable> flip deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 73c20eb commit f9ffb87

1 file changed

Lines changed: 56 additions & 48 deletions

File tree

src/Packages/Audience/Runtime/Transport/HttpTransport.cs

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#nullable enable
2+
13
using System;
24
using System.Collections.Generic;
35
using System.IO;
@@ -11,39 +13,31 @@
1113
namespace Immutable.Audience
1214
{
1315
/// <summary>
14-
/// Reads event batches from <see cref="DiskStore"/>, gzip-compresses them,
15-
/// and POSTs to <c>/v1/audience/messages</c>. Runs entirely on background
16-
/// threads via <see cref="HttpClient"/> — no main thread involvement.
17-
///
18-
/// <para>Retry policy: 5xx and network errors keep events on disk with
19-
/// exponential backoff (5s → 10s → 20s → 60s cap). Escalation only
20-
/// occurs after the previous backoff window has elapsed — premature
21-
/// retries don't grow the backoff further. 4xx and 200 with rejected
22-
/// events are dropped — they won't succeed on retry.</para>
16+
/// Sends queued events from <see cref="DiskStore"/> to the Audience backend.
2317
/// </summary>
2418
internal sealed class HttpTransport : IDisposable
2519
{
2620
private readonly DiskStore _store;
2721
private readonly string _url;
2822
private readonly string _publishableKey;
2923
private readonly HttpClient _client;
30-
private readonly Action<AudienceError> _onError;
24+
private readonly Action<AudienceError>? _onError;
3125
private readonly Func<DateTime> _getUtcNow;
3226

3327
private int _consecutiveFailures;
3428
private DateTime? _nextAttemptAt;
3529

36-
/// <param name="store">Disk store to read batches from.</param>
37-
/// <param name="publishableKey">Sent as <c>x-immutable-publishable-key</c> header.</param>
38-
/// <param name="onError">Optional error callback. Never throws to the caller.</param>
39-
/// <param name="handler">Optional HttpMessageHandler for testing.</param>
40-
/// <param name="getUtcNow">Optional UTC clock for deterministic testing of backoff timing.</param>
30+
/// <param name="store">Source of event batches.</param>
31+
/// <param name="publishableKey">Studio API key. Sent as <c>x-immutable-publishable-key</c> on every request.</param>
32+
/// <param name="onError">Optional failure callback. Exceptions thrown inside it are caught and ignored.</param>
33+
/// <param name="handler">Optional <see cref="HttpMessageHandler"/>. Callers can supply a custom pipeline (e.g. specific for test purposes). Defaults to the standard handler when null.</param>
34+
/// <param name="getUtcNow">Optional UTC clock source used for backoff timing (e.g. swappable for deterministic time). Defaults to <c>DateTime.UtcNow</c> when null.</param>
4135
internal HttpTransport(
4236
DiskStore store,
4337
string publishableKey,
44-
Action<AudienceError> onError = null,
45-
HttpMessageHandler handler = null,
46-
Func<DateTime> getUtcNow = null)
38+
Action<AudienceError>? onError = null,
39+
HttpMessageHandler? handler = null,
40+
Func<DateTime>? getUtcNow = null)
4741
{
4842
_store = store ?? throw new ArgumentNullException(nameof(store));
4943
_publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey));
@@ -55,19 +49,33 @@ internal HttpTransport(
5549
}
5650

5751
/// <summary>
58-
/// Reads one batch from disk and sends it to the backend.
59-
/// Returns true if a batch was sent (regardless of outcome), false if the queue was empty.
52+
/// Attempts to process one batch: reads it from disk, gzips it, and POSTs it.
53+
/// Returns true if a batch was consumed (outcome irrelevant), false if the queue was empty.
6054
/// </summary>
6155
internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
6256
{
6357
var batch = _store.ReadBatch(Constants.DefaultFlushSize);
6458
if (batch.Count == 0)
6559
return false;
6660

67-
var payload = BuildPayload(batch);
61+
string? payload;
62+
try
63+
{
64+
payload = BuildPayload(batch);
65+
}
66+
catch (Exception ex)
67+
{
68+
// Non-IOException = unrecoverable storage failure (e.g. permissions);
69+
// retry won't help. Drop the batch, report via onError.
70+
_store.Delete(batch);
71+
NotifyError(AudienceErrorCode.FlushFailed, $"Local storage read failed: {ex.Message}");
72+
return true;
73+
}
74+
6875
if (payload == null)
6976
{
70-
// All files were unreadable — delete them and move on.
77+
// Every file was unreadable (deleted or locked between ReadBatch and now).
78+
// Drop the refs, return.
7179
_store.Delete(batch);
7280
return true;
7381
}
@@ -106,10 +114,10 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
106114
}
107115
catch (OperationCanceledException) when (ct.IsCancellationRequested)
108116
{
109-
// Shutdown requested via cancellation token — don't increment failures,
110-
// events stay on disk. HttpClient timeouts throw TaskCanceledException too;
111-
// the `when` guard ensures those fall through to the general Exception
112-
// handler so backoff and the NetworkError callback fire correctly.
117+
// Caller cancelled the token (e.g. on shutdown). Events stay on
118+
// disk, no failure recorded. HttpClient timeouts throw the same
119+
// exception but without ct.IsCancellationRequested set, so they
120+
// fall through to the Exception branch below and trigger backoff.
113121
}
114122
catch (Exception ex)
115123
{
@@ -121,8 +129,8 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
121129
}
122130

123131
/// <summary>
124-
/// Backoff delay in milliseconds based on consecutive failures.
125-
/// Schedule: 0 → 5s → 10s → 20s → 40s → 60s cap.
132+
/// Delay in ms before the next attempt, by consecutive-failure count:
133+
/// 0 → 0, 1 → 5_000, 2 → 10_000, 3 → 20_000, 4 → 40_000, 5+ → 60_000.
126134
/// </summary>
127135
internal int BackoffMs => _consecutiveFailures switch
128136
{
@@ -135,16 +143,14 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
135143
};
136144

137145
/// <summary>
138-
/// Timestamp after which the next send attempt should run. Null when there's
139-
/// no active backoff (never failed, or last attempt succeeded). Callers should
140-
/// skip sending while <c>UtcNow &lt; NextAttemptAt</c>.
146+
/// Earliest UTC time at which the next attempt may run.
147+
/// Null when no backoff is active (never failed, or last attempt succeeded).
141148
/// </summary>
142149
internal DateTime? NextAttemptAt => _nextAttemptAt;
143150

144151
/// <summary>
145-
/// True if a failure occurred recently and the backoff window has not yet
146-
/// elapsed. Becomes false naturally once enough time passes, allowing the
147-
/// next send attempt to proceed.
152+
/// True while <c>UtcNow &lt; NextAttemptAt</c>. Flips false as the clock
153+
/// advances; no reset required.
148154
/// </summary>
149155
internal bool IsInBackoffWindow => _getUtcNow() < _nextAttemptAt;
150156

@@ -157,10 +163,10 @@ private void RecordFailure()
157163
{
158164
var now = _getUtcNow();
159165

160-
// If we're still inside the previous backoff window, the caller retried
161-
// before honoring the wait. Don't escalate — the existing NextAttemptAt
162-
// deadline stands. When _nextAttemptAt is null (no prior failure),
163-
// `now < null` is false via lifted nullable comparison, so we escalate.
166+
// Premature retry: caller failed again inside the existing window. Keep
167+
// the prior deadline so repeat polls don't compound backoff. First-failure
168+
// case is safe: _nextAttemptAt is null and `now < null` evaluates to
169+
// false, so we fall through and set it.
164170
if (now < _nextAttemptAt)
165171
return;
166172

@@ -175,15 +181,14 @@ private void ResetBackoff()
175181
}
176182

177183
/// <summary>
178-
/// Reads file contents for each path and builds the batch JSON payload:
179-
/// <c>{"batch":[msg1,msg2,...]}</c>
184+
/// Reads each path and wraps the concatenated JSON bodies in
185+
/// <c>{"batch":[msg1,msg2,...]}</c>.
180186
/// </summary>
181187
/// <returns>
182-
/// The batch JSON string, or <c>null</c> when every path failed to read
183-
/// (files disappeared, empty, or locked). The caller treats <c>null</c>
184-
/// as "nothing to send" and deletes the path list.
188+
/// The batched JSON, or <c>null</c> if every path was unreadable. Caller
189+
/// treats <c>null</c> as "nothing to send" and deletes the path list.
185190
/// </returns>
186-
private static string BuildPayload(IReadOnlyList<string> paths)
191+
private static string? BuildPayload(IReadOnlyList<string> paths)
187192
{
188193
var sb = new StringBuilder("{\"batch\":[");
189194
var count = 0;
@@ -199,9 +204,11 @@ private static string BuildPayload(IReadOnlyList<string> paths)
199204
}
200205
catch (IOException)
201206
{
202-
// File disappeared, locked, or path vanished between ReadBatch
203-
// and now — skip it. Non-IO errors like UnauthorizedAccessException
204-
// indicate real problems and are allowed to propagate.
207+
// Transient disk race: the file was deleted or locked between
208+
// ReadBatch and now. Safe to skip — the remaining paths in the
209+
// batch may still read fine. Non-IOException failures escape
210+
// and are handled by the caller (SendBatchAsync) as a batch-
211+
// wide storage error.
205212
}
206213
}
207214

@@ -220,7 +227,8 @@ private void NotifyError(AudienceErrorCode code, string message)
220227
}
221228
catch
222229
{
223-
// Error callback itself threw — swallow to protect the SDK.
230+
// Consumer callback threw. Swallow: the SDK must not surface
231+
// exceptions through the error-reporting path itself.
224232
}
225233
}
226234
}

0 commit comments

Comments
 (0)