diff --git a/src/Packages/Audience/Runtime/AudienceError.cs b/src/Packages/Audience/Runtime/AudienceError.cs
index c8da6704..dd6f0672 100644
--- a/src/Packages/Audience/Runtime/AudienceError.cs
+++ b/src/Packages/Audience/Runtime/AudienceError.cs
@@ -1,7 +1,46 @@
+#nullable enable
+
using System;
+using System.Collections.Generic;
namespace Immutable.Audience
{
+ /// One validation failure the backend reported for a single message.
+ public sealed class RejectionError
+ {
+ /// Name of the offending field, or a message-level sentinel when no single field applies.
+ public string Field { get; }
+
+ /// Machine-readable reason, e.g. INVALID_ENUM, MISSING_REQUIRED_FIELD.
+ public string Code { get; }
+
+ /// Human-readable description. Not contractual; wording may change.
+ public string Message { get; }
+
+ internal RejectionError(string field, string code, string message)
+ {
+ Field = field;
+ Code = code;
+ Message = message;
+ }
+ }
+
+ /// A single message the backend rejected, with every reason it gave.
+ public sealed class MessageRejection
+ {
+ /// Echoes the client-supplied messageId verbatim.
+ public string MessageId { get; }
+
+ /// Every violation reported for this message.
+ public IReadOnlyList Errors { get; }
+
+ internal MessageRejection(string messageId, IReadOnlyList errors)
+ {
+ MessageId = messageId;
+ Errors = errors;
+ }
+ }
+
///
/// Categorises errors raised through .
///
@@ -66,16 +105,25 @@ public class AudienceError : Exception
///
public AudienceErrorCode Code { get; }
+ ///
+ /// Per-message rejection detail, when is
+ /// and the backend
+ /// reported one.
+ ///
+ public IReadOnlyList? Rejections { get; }
+
///
/// Wraps a code and message into an for
/// delivery through .
///
/// The reason for the failure.
/// Human-readable description.
- public AudienceError(AudienceErrorCode code, string message)
+ /// Per-message rejection detail, when the backend reported one.
+ public AudienceError(AudienceErrorCode code, string message, IReadOnlyList? rejections = null)
: base(message)
{
Code = code;
+ Rejections = rejections;
}
}
}
diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs
index fa62d067..d93eed27 100644
--- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs
+++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs
@@ -102,18 +102,23 @@ internal async Task 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)
{
@@ -137,10 +142,12 @@ internal async Task 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
@@ -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 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? Rejections)> ParseRejectedResult(
+ HttpResponseMessage response, CancellationToken ct = default)
{
string body;
try
@@ -293,26 +302,91 @@ private static async Task 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? 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? ExtractRejections(Dictionary parsed)
+ {
+ if (!parsed.TryGetValue("rejections", out var raw) || raw is not List