Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion src/Packages/Audience/Runtime/AudienceError.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
#nullable enable

using System;
using System.Collections.Generic;

namespace Immutable.Audience
{
/// <summary>One validation failure the backend reported for a single message.</summary>
public sealed class RejectionError
{
/// <summary>Name of the offending field, or a message-level sentinel when no single field applies.</summary>
public string Field { get; }

/// <summary>Machine-readable reason, e.g. <c>INVALID_ENUM</c>, <c>MISSING_REQUIRED_FIELD</c>.</summary>
public string Code { get; }

/// <summary>Human-readable description. Not contractual; wording may change.</summary>
public string Message { get; }

internal RejectionError(string field, string code, string message)
{
Field = field;
Code = code;
Message = message;
}
}

/// <summary>A single message the backend rejected, with every reason it gave.</summary>
public sealed class MessageRejection
{
/// <summary>Echoes the client-supplied messageId verbatim.</summary>
public string MessageId { get; }

/// <summary>Every violation reported for this message.</summary>
public IReadOnlyList<RejectionError> Errors { get; }

internal MessageRejection(string messageId, IReadOnlyList<RejectionError> errors)
{
MessageId = messageId;
Errors = errors;
}
}

/// <summary>
/// Categorises errors raised through <see cref="AudienceConfig.OnError"/>.
/// </summary>
Expand Down Expand Up @@ -66,16 +105,25 @@ public class AudienceError : Exception
/// </summary>
public AudienceErrorCode Code { get; }

/// <summary>
/// Per-message rejection detail, when <see cref="Code"/> is
/// <see cref="AudienceErrorCode.ValidationRejected"/> and the backend
/// reported one.
/// </summary>
public IReadOnlyList<MessageRejection>? Rejections { get; }

/// <summary>
/// Wraps a code and message into an <see cref="AudienceError"/> for
/// delivery through <see cref="AudienceConfig.OnError"/>.
/// </summary>
/// <param name="code">The reason for the failure.</param>
/// <param name="message">Human-readable description.</param>
public AudienceError(AudienceErrorCode code, string message)
/// <param name="rejections">Per-message rejection detail, when the backend reported one.</param>
public AudienceError(AudienceErrorCode code, string message, IReadOnlyList<MessageRejection>? rejections = null)
: base(message)
{
Code = code;
Rejections = rejections;
}
}
}
124 changes: 99 additions & 25 deletions src/Packages/Audience/Runtime/Transport/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,23 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
if (statusCode >= 200 && statusCode < 300)
{
// Server accepted the batch. Count how many messages it
// rejected; if any, tell the studio via onError. Rejected
// messages are validation failures, so retrying won't help.
// The batch is deleted either way.
var rejected = await ParseRejectedCount(response, ct).ConfigureAwait(false);
// rejected; if any, log the detail and tell the studio via
// onError. Rejected messages are validation failures, so
// retrying won't help. The batch is deleted either way.
var (rejected, rejections) = await ParseRejectedResult(response, ct).ConfigureAwait(false);
_store.Delete(batch);
ResetBackoff();
if (rejected > 0)
// rejected and rejections are parsed independently from the same body;
// fall back to rejections.Count so a body where they've drifted (e.g.
// rejected wasn't updated but rejections was) doesn't silently drop detail.
var rejectedCount = Math.Max(rejected, rejections?.Count ?? 0);
if (rejectedCount > 0)
{
LogRejections(rejections);
NotifyError(AudienceErrorCode.ValidationRejected,
$"Batch partially rejected: {rejected} of {batch.Count} events dropped");
$"Batch partially rejected: {rejectedCount} of {batch.Count} events dropped", rejections);
}
LogFlushOutcome(rejected == 0, batch.Count);
LogFlushOutcome(rejectedCount == 0, batch.Count);
}
else if (statusCode == 429)
{
Expand All @@ -137,10 +142,12 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
// publishable key", "missing field X", etc.) rather than a
// bare status code.
var rejectionBody = await ReadBodyForErrorAsync(response).ConfigureAwait(false);
var rejections = ExtractRejectionsFromBody(rejectionBody);
_store.Delete(batch);
ResetBackoff();
LogRejections(rejections);
NotifyError(AudienceErrorCode.ValidationRejected,
FormatHttpError("Batch rejected", statusCode, rejectionBody));
FormatHttpError("Batch rejected", statusCode, rejectionBody), rejections);
LogFlushOutcome(false, batch.Count);
}
else
Expand Down Expand Up @@ -277,10 +284,12 @@ private void ResetBackoff()
return sb.ToString();
}

// Reads the response body and pulls out the "rejected" count. Returns
// 0 if the body is missing or unreadable. The body is only for
// reporting, so failing to read it must not break the success path.
private static async Task<int> ParseRejectedCount(HttpResponseMessage response, CancellationToken ct = default)
// Reads the response body and pulls out the rejected count and
// per-message rejection detail. Returns (0, null) if the body is
// missing or unreadable. The body is only for reporting, so failing
// to read it must not break the success path.
private static async Task<(int Rejected, IReadOnlyList<MessageRejection>? Rejections)> ParseRejectedResult(
HttpResponseMessage response, CancellationToken ct = default)
{
string body;
try
Expand All @@ -293,26 +302,91 @@ private static async Task<int> ParseRejectedCount(HttpResponseMessage response,
}
catch (Exception ex)
{
Log.Warn(AudienceLogs.ParseRejectedCountThrew(ex));
return 0;
Log.Warn(AudienceLogs.ParseRejectedResultThrew(ex));
return (0, null);
}
if (string.IsNullOrEmpty(body)) return 0;
if (string.IsNullOrEmpty(body)) return (0, null);

try
{
var parsed = JsonReader.DeserializeObject(body);
if (!parsed.TryGetValue("rejected", out var raw)) return 0;
return raw switch
{
int i => i,
long l => (int)l,
_ => 0,
};
var rejected = parsed.TryGetValue("rejected", out var raw)
? raw switch { int i => i, long l => (int)l, _ => 0 }
: 0;
return (rejected, ExtractRejections(parsed));
}
catch (FormatException)
{
return 0;
return (0, null);
}
}

private static IReadOnlyList<MessageRejection>? ExtractRejectionsFromBody(string? body)
{
if (string.IsNullOrEmpty(body)) return null;
try
{
return ExtractRejections(JsonReader.DeserializeObject(body));
}
catch (FormatException)
{
return null;
}
}

// Best-effort: malformed entries are skipped rather than failing the whole parse.
private static List<MessageRejection>? ExtractRejections(Dictionary<string, object> parsed)
{
if (!parsed.TryGetValue("rejections", out var raw) || raw is not List<object> list || list.Count == 0)
return null;

var result = new List<MessageRejection>(list.Count);
foreach (var item in list)
{
if (item is not Dictionary<string, object> obj) continue;
if (!(obj.TryGetValue("messageId", out var midObj) && midObj is string messageId)) continue;

var errors = new List<RejectionError>();
if (obj.TryGetValue("errors", out var errorsRaw) && errorsRaw is List<object> errorList)
{
foreach (var errItem in errorList)
{
if (errItem is not Dictionary<string, object> errObj) continue;
errors.Add(new RejectionError(
errObj.TryGetValue("field", out var f) ? f as string ?? "" : "",
errObj.TryGetValue("code", out var c) ? c as string ?? "" : "",
errObj.TryGetValue("message", out var m) ? m as string ?? "" : ""));
}
}
result.Add(new MessageRejection(messageId, errors));
}
return result.Count > 0 ? result : null;
}

// Fires unconditionally, independent of onError, so a rejection the SDK
// didn't catch client-side doesn't fail silently. Log.Error, not Warn:
// this is lost data, not an advisory. One call per batch, not one per
// rejected message: a batch can carry many rejections, and that many
// separate Debug.LogError calls would flood the Editor console (and
// fail an unsuspecting test via Unity's LogAssert).
private static void LogRejections(IReadOnlyList<MessageRejection>? rejections)
{
if (rejections == null || rejections.Count == 0) return;

var detail = new StringBuilder();
for (var i = 0; i < rejections.Count; i++)
{
var rejection = rejections[i];
if (i > 0) detail.Append('\n');
detail.Append(" ").Append(rejection.MessageId).Append(": ");
for (var j = 0; j < rejection.Errors.Count; j++)
{
if (j > 0) detail.Append("; ");
var e = rejection.Errors[j];
detail.Append(e.Field).Append(' ').Append(e.Code).Append(": ").Append(e.Message);
}
}
Log.Error(AudienceLogs.MessageRejectedByServer(rejections.Count, detail.ToString()));
}

// Best-effort body extraction; null on read failure.
Expand All @@ -335,12 +409,12 @@ private static string FormatHttpError(string prefix, int statusCode, string? bod
? $"{prefix} with {statusCode}"
: $"{prefix} with {statusCode}: {body}";

private void NotifyError(AudienceErrorCode code, string message)
private void NotifyError(AudienceErrorCode code, string message, IReadOnlyList<MessageRejection>? rejections = null)
{
if (_onError == null) return;
try
{
_onError(new AudienceError(code, message));
_onError(new AudienceError(code, message, rejections));
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ private static void Install()

// Set before the collectors below run so a collector failure logs via Debug.Log, not Console.WriteLine.
if (Log.Writer == null) Log.Writer = Debug.Log;
if (Log.ErrorWriter == null) Log.ErrorWriter = Debug.LogError;

// Captured once on main thread; ReadOnlyDictionary blocks downstream mutation.
// Each collector is isolated so one throwing can't block the other's provider or abort Install().
Expand Down
35 changes: 24 additions & 11 deletions src/Packages/Audience/Runtime/Utility/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,36 @@ internal static class Log
// Tests set this to capture output; AudienceUnityHooks sets it to Debug.Log.
internal static Action<string>? Writer { get; set; }

// Separate from Writer so production wiring can route this to a real
// Debug.LogError (red Editor console entry, picked up by crash/error
// reporting integrations) while Writer stays on plain Debug.Log.
// Tests set this to capture output; AudienceUnityHooks sets it to Debug.LogError.
internal static Action<string>? ErrorWriter { get; set; }

internal static void Debug(string message)
{
if (!Enabled) return;
Emit($"{Prefix} {message}");
Emit(Writer, $"{Prefix} {message}");
}

internal static void Warn(string message) =>
Emit($"{Prefix} WARN: {message}");
Emit(Writer, $"{Prefix} WARN: {message}");

// Fires unconditionally, independent of Enabled, like Warn.
internal static void Error(string message) =>
Emit(ErrorWriter, $"{Prefix} ERROR: {message}");

private static void Emit(string line)
private static void Emit(Action<string>? writer, string line)
{
// Swallow anything the Writer or Console throws so Log.Warn and
// Log.Debug never throw themselves. If they did, an exception from
// logging inside a catch block would reach the background timer
// and crash the game on modern .NET.
// Swallow anything the writer or Console throws so the Log methods
// never throw themselves. If they did, an exception from logging
// inside a catch block would reach the background timer and
// crash the game on modern .NET.
try
{
if (Writer != null)
if (writer != null)
{
Writer(line);
writer(line);
return;
}
Console.WriteLine(line);
Expand Down Expand Up @@ -124,8 +134,11 @@ internal static string SendBatchUnexpected(Exception ex) =>
internal static string FlushOutcome(bool ok, int count) =>
$"flush {(ok ? "ok" : "failed")} ({count} messages)";

internal static string ParseRejectedCountThrew(Exception ex) =>
$"ParseRejectedCount threw {ex.GetType().Name}: {ex.Message}";
internal static string ParseRejectedResultThrew(Exception ex) =>
$"ParseRejectedResult threw {ex.GetType().Name}: {ex.Message}";

internal static string MessageRejectedByServer(int count, string detail) =>
$"{count} message(s) rejected by the server:\n{detail}";

// ---- Session ----

Expand Down
Loading
Loading