← Todos los artículos

Tu API hizo el cobro dos veces: el peligro de reintentar un POST

Un timeout no demuestra que el cobro falló. Aprende a revisar los retries de .NET, reutilizar claves idempotentes y reconciliar efectos sin prometer exactly-once.

7 de octubre de 2026 · · 13 min de lectura
Dos solicitudes repetidas atraviesan un control de idempotencia y producen un único registro de operación

Este artículo continúa Por qué crear un HttpClient por petición puede agotar puertos. Allí el foco era reutilizar conexiones con IHttpClientFactory; aquí aparece el riesgo que queda cuando una solicitud falla después de salir: el cliente puede no saber si el servidor ya aplicó el efecto.

Imagina un POST para crear un pago. El proveedor registra la operación, pero la conexión se cierra antes de que llegue la respuesta. El cliente ve HttpRequestException o timeout. Si interpreta ese error como “no se cobró” y reintenta con una clave nueva, puede crear un segundo efecto. Un retry es una política de transporte; la idempotencia es un contrato del efecto remoto. Son capas distintas.

La demo que acompaña este texto es local, usa Microsoft.Extensions.Http.Resilience 10.10.0 con SDK 10.0.401 y net10.0, y no conecta con ningún proveedor. Su FakeHttpMessageHandler registra efectos sintéticos en un ledger en memoria, pierde deliberadamente la primera respuesta y permite observar los escenarios sin dinero, tarjetas, secretos ni red.

Qué hace el retry estándar

Microsoft documenta que AddStandardResilienceHandler encadena limitación de tasa, timeout total, retry, circuit breaker y timeout por intento. En sus valores predeterminados, el retry contempla respuestas 500 o superiores, 408, 429, HttpRequestException y TimeoutRejectedException; la tabla de la documentación usa tres reintentos, backoff exponencial y jitter. Y el retry se aplica a todos los métodos HTTP, incluido POST, salvo que se configure una exclusión.

La demo fija valores deterministas para que las aserciones sean sencillas: un solo reintento adicional, delay cero y jitter desactivado. La configuración corresponde al código ejecutable:

services.AddHttpClient("payments")
    .ConfigurePrimaryHttpMessageHandler(() => fake)
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 1;
        options.Retry.Delay = TimeSpan.Zero;
        options.Retry.UseJitter = false;
        if (disableUnsafeMethods)
        {
            options.Retry.DisableForUnsafeHttpMethods();
        }
    });

DisableForUnsafeHttpMethods() excluye POST, PATCH, PUT, DELETE y CONNECT. Es una protección prudente para escrituras cuya semántica todavía no conoces, no un detector de operaciones de negocio. Un PUT puede ser idempotente según HTTP y aun así disparar efectos secundarios mal diseñados; un POST puede tener deduplicación explícita en el contrato de tu API.

Safe, idempotent e incierto no son sinónimos

El RFC 9110, sección 9.2 llama safe a un método cuya semántica prevista es de solo lectura e idempotent a uno donde varias solicitudes idénticas tienen el mismo efecto previsto que una sola. Los métodos seguros son idempotentes y el RFC incluye PUT y DELETE entre los idempotentes, pero POST no recibe esa garantía general.

Eso no dice qué ocurrió en un caso concreto. Si la respuesta se pierde después del commit remoto, el resultado del pago es unknown, no “falló”. El cliente debe consultar por la referencia o la clave, esperar un webhook, o llevar el caso a reconciliación según el contrato del proveedor. Desactivar el retry evita repetir automáticamente, pero no transforma el estado incierto en un estado seguro.

La clave nace antes del primer intento

La clave idempotente representa la operación lógica —por ejemplo, crear el pago del pedido— y no una llamada individual. Se genera o recupera antes de construir el primer HttpRequestMessage, se persiste junto con el pedido o una outbox y se envía igual en cada reintento. No debe crearse un Guid nuevo dentro de una política de retry.

El servidor necesita asociar más que una cadena:

  • operationKey: valor único para esa operación.
  • payloadFingerprint: huella de una representación canónica de importe, moneda, pedido y destino; misma clave con otra huella es conflicto.
  • authScope: cuenta, tenant o merchant y proveedor al que pertenece la clave.
  • status: in_progress, succeeded, failed o unknown; unknown queda para reconciliación.
  • providerReference, respuesta sanitizada y fechas de creación/expiración.

La demo usa X-Auth-Scope solo como un stub para hacer visible el alcance en un proceso local. En una API real, el servidor debe derivar ese alcance de la identidad autenticada y de la autorización comprobada; nunca debe confiar en que el cliente pueda elegir libremente ese header.

La demo calcula una huella SHA-256 del JSON que construye JsonContent. Eso es suficiente para que el harness sea determinista, pero no es una política de producción: una huella real debe canonicalizar campos, normalizar tipos y decidir qué propiedades forman parte de la operación.

Tres escenarios que no debes mezclar

El fake registra providerOperationCount como efecto remoto sintético y AttemptCount como envíos de transporte. La misma clave se muestra parcialmente en la salida.

Escenario Envíos Efectos Resultado
Retry estándar sin idempotencia 2 2 la primera respuesta se pierde y el reintento crea otra operación
DisableForUnsafeHttpMethods() 1 1 se propaga la excepción; el resultado queda incierto
Retry con la misma clave y huella 2 1 el segundo envío reproduce la respuesta sintética

En el tercer caso, el fake reserva atómicamente la combinación de alcance y clave. Si el primer envío confirma el efecto y lanza la excepción, el segundo encuentra la entrada y devuelve la misma referencia. El retry no “deshace” el primer efecto: la deduplicación del servidor decide que es un replay.

El demo también prueba por HTTP una huella distinta con la misma clave: devuelve 409 Conflict sin crear otro efecto. Para un registro previamente unknown, devuelve 202 Accepted sin fingir una referencia exitosa. El caller queda obligado a reconciliar, no a generar una clave nueva automáticamente.

Ledger: restricción atómica, no SELECT seguido de INSERT

Dos workers pueden recibir al mismo tiempo la misma clave. Un patrón de “compruebo si existe y luego inserto” deja una ventana en la que ambos creen ser el primero. La demo protege el diccionario con lock y usa una clave compuesta por tupla (authScope, key); una base de datos real debe expresar la misma regla con una restricción única y una transacción apropiada.

El contrato probado es:

  1. clave y huella nuevas: reservar una operación;
  2. misma clave y misma huella: reproducir el resultado;
  3. misma clave y huella distinta: devolver conflicto;
  4. misma clave en otro alcance autenticado: operación independiente;
  5. dos llamadas concurrentes: solo una reserva y un efecto;
  6. estado unknown: mantener la operación para reconciliación, sin crear otra.

No hay que convertir el lock de memoria en una promesa distribuida. Si hay varias réplicas, el ledger debe ser compartido y durable; Dictionary local solo sirve para este ejemplo ejecutable.

Ejecuta la demo sin un proveedor real

El proyecto está en http-retries-idempotency/demo. Fija el SDK mediante global.json y la dependencia en el .csproj. Desde esa carpeta:

dotnet restore
dotnet build --no-restore --configuration Release
dotnet run --no-build --configuration Release

global.json fija el SDK y el proyecto fija la versión directa del paquete; como este ejemplo no incluye un packages.lock.json, las dependencias transitivas se resuelven durante dotnet restore. No presentes esta demo como un grafo transitivo bloqueado.

La ejecución local del 7 de octubre de 2026 terminó con compilación sin advertencias y todas las aserciones aprobadas:

PASS default-retry-no-idempotency attempts=2 effects=2 key=idem…
PASS unsafe-no-retry attempts=1 effects=1 outcome=unknown key=idem…
PASS idempotency-replay attempts=2 effects=1 sameResponse=true key=idem…
PASS payload-conflict status=409 attempts=2 effects=1 key=idem…
PASS unknown-state status=202 attempts=1 effects=0 key=idem…
PASS ledger-contract same-key payload-mismatch auth-scope concurrency unknown
ALL TESTS PASSED

El resultado es una prueba de la lógica de este harness, no evidencia de un banco, procesador o red reales. No es un benchmark y no mide latencia, CPU o throughput.

Qué hacer con una operación real

Para un pago o reserva, la ruta suele ser:

  1. crear el pedido y su clave antes del primer intento;
  2. guardar la huella y el alcance autorizado en un ledger durable;
  3. enviar la misma clave al proveedor en cada intento permitido;
  4. aceptar unknown cuando se pierde la respuesta;
  5. consultar por clave o referencia y procesar webhooks/reconciliación;
  6. devolver al cliente un resultado estable o un estado pendiente, nunca un nuevo efecto silencioso.

La documentación de Stripe sobre solicitudes idempotentes es un ejemplo de proveedor: conserva el resultado asociado a la clave, compara parámetros y tiene su propia retención y conflictos concurrentes. Sus reglas no son un estándar transferible. El TTL del ledger propio debe cubrir la ventana real de reintentos y reconciliación; no copies “24 horas” sin leer el contrato del proveedor.

Una outbox puede hacer atómica la escritura local del pedido y del evento que un worker debe enviar. No vuelve atómica esa base de datos con el procesador externo. Si el proceso cae después del efecto remoto y antes de guardar la respuesta, todavía hace falta reconciliar. exactly-once puede describir una propiedad acotada de un ledger o de un contrato que la garantice; no es una promesa universal de toda la cadena.

Anexo: demo autocontenida

La demo completa probada está reproducida aquí para que el ejemplo sea legible sin depender de una ruta editorial local ni de un repositorio externo. Los tres archivos forman un proyecto de consola; guárdalos en una misma carpeta, ejecuta los comandos anteriores y conserva la referencia directa del paquete. El código intercepta la URL sintética en memoria: no abre una conexión.

global.json

{
  "sdk": {
    "version": "10.0.401",
    "rollForward": "latestPatch",
    "allowPrerelease": false
  }
}

RetryIdempotencyDemo.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
  </ItemGroup>
</Project>

Program.cs

using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;

await RunDefaultRetryWithoutIdempotencyAsync();
await RunUnsafeMethodWithoutRetryAsync();
await RunIdempotentReplayAsync();
await RunPayloadConflictAsync();
await RunUnknownStateAsync();
RunLedgerContractTests();

Console.WriteLine("ALL TESTS PASSED");

static async Task RunDefaultRetryWithoutIdempotencyAsync()
{
    var ledger = new InMemoryLedger();
    var fake = new FakeHttpMessageHandler(ledger, useIdempotency: false, failFirstAfterEffect: true);
    using var provider = BuildProvider(fake, disableUnsafeMethods: false);
    var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("payments");
    var key = "idem-default-001";

    using var request = CreatePaymentRequest(key, "tenant-a", 1000, "MXN", "order-001");
    using var response = await client.SendAsync(request);

    Assert(response.IsSuccessStatusCode, "El retry default debe devolver la segunda respuesta.");
    Assert(fake.AttemptCount == 2, "El retry determinista debe hacer dos envíos.");
    Assert(ledger.ProviderOperationCount == 2, "Sin idempotencia, ambos envíos producen efectos.");
    Assert(await response.Content.ReadAsStringAsync() == "{\"providerReference\":\"provider-op-2\"}", "La segunda operación debe ser visible.");
    Console.WriteLine($"PASS default-retry-no-idempotency attempts={fake.AttemptCount} effects={ledger.ProviderOperationCount} key={Mask(key)}");
}

static async Task RunUnsafeMethodWithoutRetryAsync()
{
    var ledger = new InMemoryLedger();
    var fake = new FakeHttpMessageHandler(ledger, useIdempotency: false, failFirstAfterEffect: true);
    using var provider = BuildProvider(fake, disableUnsafeMethods: true);
    var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("payments");
    var key = "idem-disabled-001";

    using var request = CreatePaymentRequest(key, "tenant-a", 1000, "MXN", "order-002");
    var exception = await CaptureAsync(() => client.SendAsync(request));

    Assert(exception is HttpRequestException, "Sin retry, la HttpRequestException debe llegar al caller.");
    Assert(fake.AttemptCount == 1, "POST excluido debe tener un solo envío.");
    Assert(ledger.ProviderOperationCount == 1, "El fake confirma un efecto antes de perder la respuesta.");
    Console.WriteLine($"PASS unsafe-no-retry attempts={fake.AttemptCount} effects={ledger.ProviderOperationCount} outcome=unknown key={Mask(key)}");
}

static async Task RunIdempotentReplayAsync()
{
    var ledger = new InMemoryLedger();
    var fake = new FakeHttpMessageHandler(ledger, useIdempotency: true, failFirstAfterEffect: true);
    using var provider = BuildProvider(fake, disableUnsafeMethods: false);
    var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("payments");
    var key = "idem-replay-001";

    using var request = CreatePaymentRequest(key, "tenant-a", 1000, "MXN", "order-003");
    using var response = await client.SendAsync(request);
    var responseBody = await response.Content.ReadAsStringAsync();

    Assert(response.IsSuccessStatusCode, "El reintento idempotente debe recuperar el resultado.");
    Assert(fake.AttemptCount == 2, "La respuesta perdida aún provoca dos envíos.");
    Assert(ledger.ProviderOperationCount == 1, "La misma clave debe producir un solo efecto.");
    Assert(responseBody == "{\"providerReference\":\"provider-op-1\"}", "El retry debe reproducir la respuesta original.");
    Console.WriteLine($"PASS idempotency-replay attempts={fake.AttemptCount} effects={ledger.ProviderOperationCount} sameResponse=true key={Mask(key)}");
}

static async Task RunPayloadConflictAsync()
{
    var ledger = new InMemoryLedger();
    var fake = new FakeHttpMessageHandler(ledger, useIdempotency: true, failFirstAfterEffect: false);
    using var provider = BuildProvider(fake, disableUnsafeMethods: false);
    var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("payments");
    var key = "idem-conflict-001";

    using (var firstRequest = CreatePaymentRequest(key, "tenant-a", 1000, "MXN", "order-004"))
    using (var firstResponse = await client.SendAsync(firstRequest))
    {
        Assert(firstResponse.IsSuccessStatusCode, "La primera petición del conflicto debe crear la operación.");
    }

    using var mismatchRequest = CreatePaymentRequest(key, "tenant-a", 2000, "MXN", "order-004");
    using var mismatchResponse = await client.SendAsync(mismatchRequest);
    Assert(mismatchResponse.StatusCode == HttpStatusCode.Conflict, "La misma clave con otra huella debe devolver 409.");
    Assert(fake.AttemptCount == 2, "El conflicto no debe activar un retry.");
    Assert(ledger.ProviderOperationCount == 1, "El conflicto no debe crear un segundo efecto.");
    Console.WriteLine($"PASS payload-conflict status=409 attempts={fake.AttemptCount} effects={ledger.ProviderOperationCount} key={Mask(key)}");
}

static async Task RunUnknownStateAsync()
{
    var ledger = new InMemoryLedger();
    const string key = "idem-unknown-001";
    ledger.SeedUnknown("tenant-a", key, FingerprintFor(1000, "MXN", "order-005"));
    var fake = new FakeHttpMessageHandler(ledger, useIdempotency: true, failFirstAfterEffect: false);
    using var provider = BuildProvider(fake, disableUnsafeMethods: false);
    var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("payments");

    using var request = CreatePaymentRequest(key, "tenant-a", 1000, "MXN", "order-005");
    using var response = await client.SendAsync(request);
    var body = await response.Content.ReadAsStringAsync();
    Assert(response.StatusCode == HttpStatusCode.Accepted, "Unknown debe exponerse como estado pendiente, no como éxito.");
    Assert(body == "operation state unknown", "Unknown debe dirigir a reconciliación.");
    Assert(fake.AttemptCount == 1 && ledger.ProviderOperationCount == 0, "Unknown no debe crear una nueva operación.");
    Console.WriteLine($"PASS unknown-state status=202 attempts={fake.AttemptCount} effects={ledger.ProviderOperationCount} key={Mask(key)}");
}

static void RunLedgerContractTests()
{
    var ledger = new InMemoryLedger();
    var first = ledger.ReserveOrReplay("tenant-a", "same-key", "fingerprint-a");
    var replay = ledger.ReserveOrReplay("tenant-a", "same-key", "fingerprint-a");
    Assert(first.Kind == ReservationKind.NewEffect && replay.Kind == ReservationKind.Replay, "La misma clave y huella deben reproducir.");
    Assert(ledger.ProviderOperationCount == 1, "El replay secuencial no duplica el efecto.");

    var mismatch = Capture(() => ledger.ReserveOrReplay("tenant-a", "same-key", "fingerprint-b"));
    Assert(mismatch is PayloadConflictException, "La misma clave con otra huella debe producir conflicto.");

    var otherScope = ledger.ReserveOrReplay("tenant-b", "same-key", "fingerprint-a");
    Assert(otherScope.Kind == ReservationKind.NewEffect, "La clave pertenece al alcance de autenticación.");
    Assert(ledger.ProviderOperationCount == 2, "Otro alcance no puede reproducir la operación ajena.");

    var concurrentLedger = new InMemoryLedger();
    var tasks = Enumerable.Range(0, 16)
        .Select(_ => Task.Run(() => concurrentLedger.ReserveOrReplay("tenant-c", "concurrent-key", "same-payload")))
        .ToArray();
    Task.WaitAll(tasks);
    Assert(concurrentLedger.ProviderOperationCount == 1, "La reserva atómica debe ganar una sola vez bajo concurrencia.");
    Assert(tasks.Count(task => task.Result.Kind == ReservationKind.NewEffect) == 1, "Solo un worker debe reservar el efecto.");

    var unknownLedger = new InMemoryLedger();
    unknownLedger.SeedUnknown("tenant-d", "unknown-key", "same-payload");
    var unknown = unknownLedger.ReserveOrReplay("tenant-d", "unknown-key", "same-payload");
    Assert(unknown.Kind == ReservationKind.Unknown, "Unknown debe pasar a reconciliación.");
    Assert(unknownLedger.ProviderOperationCount == 0, "Unknown no crea automáticamente una nueva operación.");

    Console.WriteLine("PASS ledger-contract same-key payload-mismatch auth-scope concurrency unknown");
}

static ServiceProvider BuildProvider(FakeHttpMessageHandler fake, bool disableUnsafeMethods)
{
    var services = new ServiceCollection();
    services.AddHttpClient("payments")
        .ConfigurePrimaryHttpMessageHandler(() => fake)
        .AddStandardResilienceHandler(options =>
        {
            options.Retry.MaxRetryAttempts = 1;
            options.Retry.Delay = TimeSpan.Zero;
            options.Retry.UseJitter = false;
            if (disableUnsafeMethods)
            {
                options.Retry.DisableForUnsafeHttpMethods();
            }
        });
    return services.BuildServiceProvider();
}

static HttpRequestMessage CreatePaymentRequest(string key, string authScope, int amount, string currency, string orderId)
{
    var request = new HttpRequestMessage(HttpMethod.Post, "https://payments.invalid/payments");
    request.Headers.Add("Idempotency-Key", key);
    request.Headers.Add("X-Auth-Scope", authScope);
    request.Content = JsonContent.Create(new { amount, currency, orderId });
    return request;
}

static string FingerprintFor(int amount, string currency, string orderId) =>
    Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{{\"amount\":{amount},\"currency\":\"{currency}\",\"orderId\":\"{orderId}\"}}")));

static async Task<Exception?> CaptureAsync(Func<Task> operation)
{
    try
    {
        await operation();
        return null;
    }
    catch (Exception exception)
    {
        return exception;
    }
}

static Exception? Capture(Func<ReservationResult> operation)
{
    try
    {
        operation();
        return null;
    }
    catch (Exception exception)
    {
        return exception;
    }
}

static string Mask(string key) => key[..Math.Min(4, key.Length)] + "…";

static void Assert(bool condition, string message)
{
    if (!condition)
    {
        throw new InvalidOperationException(message);
    }
}

sealed class FakeHttpMessageHandler(InMemoryLedger ledger, bool useIdempotency, bool failFirstAfterEffect) : HttpMessageHandler
{
    private int _attemptCount;
    private int _firstFailure = 0;

    public int AttemptCount => Volatile.Read(ref _attemptCount);

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Interlocked.Increment(ref _attemptCount);
        var key = request.Headers.GetValues("Idempotency-Key").Single();
        var authScope = request.Headers.GetValues("X-Auth-Scope").Single();
        var payload = await request.Content!.ReadAsStringAsync(cancellationToken);
        var fingerprint = Fingerprint(payload);
        ReservationResult reservation;
        try
        {
            reservation = useIdempotency
                ? ledger.ReserveOrReplay(authScope, key, fingerprint)
                : ledger.CreateUnconditionally();
        }
        catch (PayloadConflictException)
        {
            return new HttpResponseMessage(HttpStatusCode.Conflict)
            {
                Content = new StringContent("payload conflict")
            };
        }

        if (reservation.Kind == ReservationKind.Conflict)
        {
            return new HttpResponseMessage(HttpStatusCode.Conflict)
            {
                Content = new StringContent("payload conflict")
            };
        }

        if (reservation.Kind == ReservationKind.Unknown)
        {
            return new HttpResponseMessage(HttpStatusCode.Accepted)
            {
                Content = new StringContent("operation state unknown")
            };
        }

        if (failFirstAfterEffect && Interlocked.CompareExchange(ref _firstFailure, 1, 0) == 0)
        {
            throw new HttpRequestException("respuesta perdida después del efecto simulado");
        }

        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent($"{{\"providerReference\":\"{reservation.ProviderReference}\"}}", Encoding.UTF8, "application/json")
        };
    }

    private static string Fingerprint(string payload) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
}

sealed class InMemoryLedger
{
    private readonly object _gate = new();
    private readonly Dictionary<(string AuthScope, string Key), LedgerEntry> _entries = new();
    private int _providerOperationCount;

    public int ProviderOperationCount => Volatile.Read(ref _providerOperationCount);

    public ReservationResult ReserveOrReplay(string authScope, string key, string payloadFingerprint)
    {
        lock (_gate)
        {
            var compositeKey = (authScope, key);
            if (_entries.TryGetValue(compositeKey, out var existing))
            {
                if (!StringComparer.Ordinal.Equals(existing.PayloadFingerprint, payloadFingerprint))
                {
                    throw new PayloadConflictException();
                }

                return existing.Status switch
                {
                    OperationStatus.Unknown => new ReservationResult(ReservationKind.Unknown, existing.ProviderReference),
                    _ => new ReservationResult(ReservationKind.Replay, existing.ProviderReference)
                };
            }

            var providerReference = $"provider-op-{Interlocked.Increment(ref _providerOperationCount)}";
            _entries.Add(compositeKey, new LedgerEntry(payloadFingerprint, OperationStatus.Succeeded, providerReference));
            return new ReservationResult(ReservationKind.NewEffect, providerReference);
        }
    }

    public ReservationResult CreateUnconditionally()
    {
        var providerReference = $"provider-op-{Interlocked.Increment(ref _providerOperationCount)}";
        return new ReservationResult(ReservationKind.NewEffect, providerReference);
    }

    public void SeedUnknown(string authScope, string key, string payloadFingerprint)
    {
        lock (_gate)
        {
            _entries.Add((authScope, key), new LedgerEntry(payloadFingerprint, OperationStatus.Unknown, "provider-unknown"));
        }
    }
}

sealed record LedgerEntry(string PayloadFingerprint, OperationStatus Status, string ProviderReference);
sealed class PayloadConflictException : Exception;
enum OperationStatus { Succeeded, Unknown }
enum ReservationKind { NewEffect, Replay, Conflict, Unknown }
readonly record struct ReservationResult(ReservationKind Kind, string ProviderReference);

Fuentes oficiales

Comentarios

Cargando…