udb 0.3.1

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>
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 idempotent 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 treated as transient (retryable).</summary>
    public IReadOnlySet<StatusCode> RetryableCodes { get; init; } = new HashSet<StatusCode>
    {
        StatusCode.Unavailable,
        StatusCode.DeadlineExceeded,
        StatusCode.ResourceExhausted,
    };
}

/// <summary>
/// Structured error raised by the generated wrappers. Carries the gRPC status
/// plus the decoded UDB <c>ErrorDetail</c> bytes 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; }

    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);
    }

    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;
    }
}

/// <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 an idempotent unary RPC with retry + backoff + jitter on transient
    /// codes. <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)
    {
        var backoff = Options.InitialBackoff;
        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 &&
                Options.RetryableCodes.Contains(ex.StatusCode) &&
                !ct.IsCancellationRequested)
            {
                await Task.Delay(NextBackoff(ref backoff), ct).ConfigureAwait(false);
            }
            catch (RpcException ex)
            {
                throw new UdbRpcException(rpcPath, ex);
            }
        }
    }

    /// <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);
    }
}