udb 0.4.21

Universal Data Broker — a Rust gRPC broker over multiple databases (Postgres, MySQL, SQLite, MongoDB, ClickHouse, Cassandra, MSSQL, Redis, Qdrant, S3, Neo4j, …) with per-tenant RLS, 2PC, sagas, and CDC.
Documentation
// <auto-generated>
// UDB C# SDK — generated-client runtime support.
//
// This file is COPIED VERBATIM by `udb sdk generate` (no template placeholders).
// It is the stable robustness core shared by every per-service wrapper emitted
// into GeneratedClient.cs: per-call deadlines, retry with exponential backoff +
// jitter on transient gRPC codes, metadata propagation, and typed error mapping
// that unpacks the `udb-error-detail-bin` trailer when present.
//
// It COMPOSES WITH the hand-written layer (UdbClient / UdbAuthClient): the
// generated wrappers reuse UdbMetadata.Headers()-equivalent metadata and never
// redefine those types.
// </auto-generated>
#nullable enable
using Grpc.Core;

namespace Udb.Client.Generated;

/// <summary>
/// Per-call tuning for the generated robustness layer. A single instance is
/// shared by every generated service wrapper; individual calls may override the
/// deadline via the optional <c>deadline</c> argument.
/// </summary>
public sealed record UdbCallOptions
{
    /// <summary>Default per-attempt deadline. <c>null</c> means no client deadline.</summary>
    public TimeSpan? Timeout { get; init; } = TimeSpan.FromSeconds(30);

    /// <summary>Maximum number of attempts (1 = no retry) for retry-safe unary calls.</summary>
    public int MaxAttempts { get; init; } = 4;

    /// <summary>Base backoff before the first retry.</summary>
    public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromMilliseconds(100);

    /// <summary>Backoff ceiling.</summary>
    public TimeSpan MaxBackoff { get; init; } = TimeSpan.FromSeconds(5);

    /// <summary>Exponential growth multiplier between attempts.</summary>
    public double BackoffMultiplier { get; init; } = 2.0;

    /// <summary>gRPC status codes retried only for read-only unary calls.</summary>
    public IReadOnlySet<StatusCode> RetryableCodes { get; init; } = new HashSet<StatusCode>
    {
        StatusCode.Unavailable,
        StatusCode.ResourceExhausted,
    };
}

/// <summary>
/// Structured error raised by the generated wrappers. Carries the gRPC status
/// plus the UDB <c>ErrorDetail</c> bytes and decoded message from the
/// <c>udb-error-detail-bin</c> trailer, when the server attached one.
/// </summary>
public sealed class UdbRpcException : Exception
{
    public StatusCode Code { get; }
    public string RpcPath { get; }

    /// <summary>Raw protobuf-encoded udb.entity.v1.ErrorDetail bytes, or null.</summary>
    public byte[]? ErrorDetail { get; }

    /// <summary>Decoded udb.entity.v1.ErrorDetail, or null when absent/malformed.</summary>
    public global::Udb.Entity.V1.ErrorDetail? DecodedErrorDetail { get; }

    /// <summary>Whether the broker marked the typed error detail as retryable.</summary>
    public bool Retryable => DecodedErrorDetail?.Retryable ?? false;

    /// <summary>Server-advised retry delay in milliseconds, or zero when absent.</summary>
    public long RetryAfterMs => DecodedErrorDetail?.RetryAfterMs ?? 0L;

    /// <summary>Typed error-detail kind, or Unspecified when absent.</summary>
    public global::Udb.Entity.V1.ErrorKind Kind =>
        DecodedErrorDetail?.Kind ?? global::Udb.Entity.V1.ErrorKind.Unspecified;

    /// <summary>Structured validation failures from the decoded ErrorDetail trailer.</summary>
    public System.Collections.Generic.IReadOnlyList<(string Field, string Description)> FieldViolations =>
        ExtractFieldViolations(DecodedErrorDetail);

    public UdbRpcException(string rpcPath, RpcException inner)
        : base($"udb: RPC {rpcPath} failed with {inner.StatusCode}: {inner.Status.Detail}", inner)
    {
        RpcPath = rpcPath;
        Code = inner.StatusCode;
        ErrorDetail = ExtractDetail(inner);
        DecodedErrorDetail = ParseDetail(ErrorDetail) ?? SynthesizeTransportDetail(Code);
    }

    private static byte[]? ExtractDetail(RpcException ex)
    {
        // The server side encodes a typed udb.entity.v1.ErrorDetail into a binary
        // trailer. Callers may decode it with ErrorDetail.Parser if desired.
        foreach (var entry in ex.Trailers)
        {
            if (entry.IsBinary &&
                string.Equals(entry.Key, "udb-error-detail-bin", StringComparison.OrdinalIgnoreCase))
            {
                return entry.ValueBytes;
            }
        }
        return null;
    }

    private static global::Udb.Entity.V1.ErrorDetail? ParseDetail(byte[]? raw)
    {
        if (raw is null || raw.Length == 0)
        {
            return null;
        }
        try
        {
            return global::Udb.Entity.V1.ErrorDetail.Parser.ParseFrom(raw);
        }
        catch
        {
            return null;
        }
    }

    private static System.Collections.Generic.IReadOnlyList<(string Field, string Description)>
        ExtractFieldViolations(global::Udb.Entity.V1.ErrorDetail? detail)
    {
        if (detail is null)
        {
            return System.Array.Empty<(string Field, string Description)>();
        }
        var field = global::Udb.Entity.V1.ErrorDetail.Descriptor.FindFieldByName("field_violations");
        if (field is null || !field.IsRepeated)
        {
            return System.Array.Empty<(string Field, string Description)>();
        }
        if (field.Accessor.GetValue(detail) is not System.Collections.IEnumerable values)
        {
            return System.Array.Empty<(string Field, string Description)>();
        }
        var outValues = new System.Collections.Generic.List<(string Field, string Description)>();
        foreach (var item in values)
        {
            if (item is not Google.Protobuf.IMessage message)
            {
                continue;
            }
            var fieldName = message.Descriptor.FindFieldByName("field");
            var description = message.Descriptor.FindFieldByName("description");
            outValues.Add((
                fieldName?.Accessor.GetValue(message) as string ?? string.Empty,
                description?.Accessor.GetValue(message) as string ?? string.Empty));
        }
        return outValues;
    }

    private static global::Udb.Entity.V1.ErrorDetail? SynthesizeTransportDetail(StatusCode code)
    {
        if (code is not (StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Cancelled))
        {
            return null;
        }
        return new global::Udb.Entity.V1.ErrorDetail
        {
            Backend = "transport",
            Operation = TransportErrorOperation(code),
            Retryable = code != StatusCode.Cancelled,
            RetryAfterMs = 0,
            Kind = global::Udb.Entity.V1.ErrorKind.Retryable,
        };
    }

    private static string TransportErrorOperation(StatusCode code)
    {
        return code switch
        {
            StatusCode.DeadlineExceeded => "deadline_exceeded",
            StatusCode.Cancelled => "cancelled",
            StatusCode.Unavailable => "unavailable",
            _ => "transport",
        };
    }
}

/// <summary>
/// Robustness primitives shared by all generated service wrappers. Forwards to
/// the buf-generated stub method (by reusing the supplied call delegate), adding
/// deadline, retry/backoff, metadata, and error mapping. Streaming calls are
/// forwarded once (never retried mid-stream) but still get deadline + metadata +
/// error mapping.
/// </summary>
public abstract class GeneratedServiceBase
{
    private static readonly Random Jitter = new();

    /// <summary>Metadata supplier  wired to the hand-written UdbMetadata.Headers().</summary>
    protected Func<Metadata> HeadersFactory { get; }

    /// <summary>Default call options for this wrapper.</summary>
    protected UdbCallOptions Options { get; }

    protected GeneratedServiceBase(Func<Metadata> headersFactory, UdbCallOptions? options)
    {
        HeadersFactory = headersFactory ?? throw new ArgumentNullException(nameof(headersFactory));
        Options = options ?? new UdbCallOptions();
    }

    private CallOptions MakeCallOptions(TimeSpan? deadline, CancellationToken ct)
    {
        var effective = deadline ?? Options.Timeout;
        DateTime? when = effective.HasValue ? DateTime.UtcNow.Add(effective.Value) : null;
        return new CallOptions(HeadersFactory(), when, ct);
    }

    /// <summary>
    /// Invoke a unary RPC with retry + backoff + jitter on transient codes.
    /// <c>DeadlineExceeded</c> is retried only when <paramref name="readOnly"/> is true.
    /// Mutations retry only when the proto contract marks the RPC replay-safe and
    /// the request carries a non-empty idempotency key.
    /// <paramref name="call"/> forwards to the stub's <c>&lt;Name&gt;Async</c>
    /// and returns the boxed <c>AsyncUnaryCall&lt;TResp&gt;</c> as <see cref="object"/>,
    /// so the closed generic stays concrete at runtime (no dynamic-to-generic
    /// cast). The strongly-typed protobuf response is returned as <c>dynamic</c>.
    /// </summary>
    protected async Task<dynamic> InvokeUnaryAsync(
        string rpcPath,
        Func<CallOptions, object> call,
        TimeSpan? deadline,
        CancellationToken ct,
        bool readOnly,
        bool replaySafe,
        object? request)
    {
        var backoff = Options.InitialBackoff;
        var hasIdempotencyKey = HasIdempotencyKey(request);
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                var co = MakeCallOptions(deadline, ct);
                dynamic rpc = call(co);
                using (rpc)
                {
                    return await rpc.ResponseAsync.ConfigureAwait(false);
                }
            }
            catch (RpcException ex) when (
                attempt < Options.MaxAttempts &&
                IsRetryable(ex.StatusCode, readOnly, replaySafe, hasIdempotencyKey) &&
                !ct.IsCancellationRequested)
            {
                await Task.Delay(NextBackoff(ref backoff), ct).ConfigureAwait(false);
            }
            catch (RpcException ex)
            {
                throw new UdbRpcException(rpcPath, ex);
            }
        }
    }

    // Retry safety is read from proto-derived operation_kind and replay_safe per RPC
    // plus the request idempotency key — never guessed from the method name.

    private bool IsRetryable(StatusCode code, bool readOnly, bool replaySafe, bool hasIdempotencyKey)
    {
        if (readOnly)
        {
            return code == StatusCode.DeadlineExceeded || Options.RetryableCodes.Contains(code);
        }
        if (!replaySafe || !hasIdempotencyKey)
        {
            return false;
        }
        return code != StatusCode.DeadlineExceeded && Options.RetryableCodes.Contains(code);
    }

    private static bool HasIdempotencyKey(object? request)
    {
        if (request is null)
        {
            return false;
        }
        var property = request.GetType().GetProperty("IdempotencyKey");
        return property?.GetValue(request) is string key && !string.IsNullOrWhiteSpace(key);
    }

    /// <summary>
    /// Open a streaming RPC (single attempt; mid-stream retry is unsafe). The
    /// boxed call object (<c>AsyncServerStreamingCall&lt;T&gt;</c>,
    /// <c>AsyncClientStreamingCall&lt;..&gt;</c>, or <c>AsyncDuplexStreamingCall&lt;..&gt;</c>)
    /// is returned as <c>dynamic</c>; drain it and map errors with
    /// <see cref="MapStreamError"/>.
    /// </summary>
    protected dynamic InvokeStreaming(
        string rpcPath,
        Func<CallOptions, object> call,
        TimeSpan? deadline,
        CancellationToken ct)
    {
        try
        {
            return call(MakeCallOptions(deadline, ct));
        }
        catch (RpcException ex)
        {
            throw new UdbRpcException(rpcPath, ex);
        }
    }

    /// <summary>Map a streaming-drain RpcException into a typed UdbRpcException.</summary>
    public static UdbRpcException MapStreamError(string rpcPath, RpcException ex)
        => new(rpcPath, ex);

    private TimeSpan NextBackoff(ref TimeSpan backoff)
    {
        var current = backoff;
        var nextMs = Math.Min(
            backoff.TotalMilliseconds * Options.BackoffMultiplier,
            Options.MaxBackoff.TotalMilliseconds);
        backoff = TimeSpan.FromMilliseconds(nextMs);
        // Full jitter on the current step.
        var jittered = current.TotalMilliseconds * (0.5 + Jitter.NextDouble() * 0.5);
        return TimeSpan.FromMilliseconds(jittered);
    }
}