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
78 changes: 78 additions & 0 deletions dotnet/EcencyApi.Tests/EngineOptionalLegTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Text.Json.Nodes;
using EcencyApi.Handlers;
using EcencyApi.Infrastructure;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// The engine layer is assembled from one required leg (balances) and three
/// enrichment legs (token metadata, market metrics, unclaimed rewards). Losing
/// enrichment must cost the decoration, not the balances: a metrics outage used
/// to surface identically to a total Hive-Engine outage — no tokens at all.
/// </summary>
public class EngineOptionalLegTests
{
[Fact]
public async Task Optional_PassesThroughASuccessfulLeg()
{
var expected = new JsonArray { new JsonObject { ["symbol"] = "LEO" } };

var result = await WalletApi.Optional(Task.FromResult(expected), "engine tokens", 2000);

Assert.Single(result);
Assert.Equal("LEO", result[0]!["symbol"]!.GetValue<string>());
}

[Fact]
public async Task Optional_DegradesAFailedLegToEmptyInsteadOfThrowing()
{
var failed = Task.FromException<JsonArray>(new Exception("upstream down"));

var result = await WalletApi.Optional(failed, "engine metrics", 2000);

Assert.Empty(result);
}

// A stalling upstream, not a throwing one, is the outage that matters here:
// the engine node pool is walked at 2s per attempt and far outlasts the leg
// budget, so catching exceptions alone would leave the caller waiting until
// its own timeout returned an empty layer — losing the balances entirely.
[Fact]
public async Task Optional_BoundsAStallingLeg()
{
var stalled = new TaskCompletionSource<JsonArray>();

var started = System.Diagnostics.Stopwatch.StartNew();
var result = await WalletApi.Optional(stalled.Task, "engine metrics", 150);
started.Stop();

Assert.Empty(result);
Assert.True(started.ElapsedMilliseconds < 2000,
$"should have given up near the timeout, took {started.ElapsedMilliseconds}ms");

// Completing late must not fault anything the caller already moved past.
stalled.SetException(new Exception("late failure"));
await Task.Delay(50);
}

// Enrichment legs must not be able to take the layer down between them: even
// with both token metadata and metrics failing, the balances still render
// (unpriced) rather than the wallet showing no engine tokens at all.
[Fact]
public async Task Optional_LetsBalancesSurviveLosingEveryEnrichmentLeg()
{
var tokens = WalletApi.Optional(
Task.FromException<JsonArray>(new Exception("tokens down")), "engine tokens", 2000);
var metrics = WalletApi.Optional(
Task.FromException<JsonArray>(new Exception("metrics down")), "engine metrics", 2000);

Assert.Empty(await tokens);
Assert.Empty(await metrics);

// ConvertEngineToken null-tolerates both, so a balance row still converts.
var converted = EcencyApi.Models.HiveEngine.ConvertEngineToken(
new JsonObject { ["symbol"] = "LEO", ["balance"] = "1.5" }, null, null, null);
Assert.Equal("LEO", JsVal.AsString(JsVal.Prop(converted, "symbol")));
}
}
40 changes: 38 additions & 2 deletions dotnet/EcencyApi/Handlers/WalletApi.Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@ public static async Task<JsonArray> FetchEngineRewards(string username)
}
}

/// <summary>
/// Degrades an enrichment leg to an empty result instead of failing the caller.
/// The engine layer must survive losing decoration; only the balances leg is
/// load-bearing.
///
/// Bounded as well as caught: a stalling upstream is the more common outage,
/// and EngineRpcClient.Find walks the whole node pool at 2s per attempt, which
/// far outlasts the engine leg's budget. Catching exceptions alone would leave
/// Task.WhenAll pending until the caller's own timeout fired and returned an
/// empty layer — discarding the balances this exists to preserve.
/// </summary>
internal static Task<JsonArray> Optional(Task<JsonArray> task, string label, int timeoutMs) =>
WithTimeout(Observed(task, label), timeoutMs, new JsonArray());

/// Never faults, so timing it out cannot leave an unobserved exception behind.
/// Logged once so a persistent upstream outage is still visible.
private static async Task<JsonArray> Observed(Task<JsonArray> task, string label)
{
try { return await task; }
catch (Exception err)
{
Console.WriteLine($"failed to get {label} {err.Message}");
return new JsonArray();
}
}

private static async Task<JsonArray> FetchEngineTokensWithBalance(string username)
{
try
Expand All @@ -163,8 +189,15 @@ private static async Task<JsonArray> FetchEngineTokensWithBalance(string usernam
var symbols = balances.Select(b => JsVal.AsString(JsVal.Prop(b, "symbol")) ?? JsVal.ToJsString(JsVal.Prop(b, "symbol")))
.Where(s => s != null).Select(s => s!).ToList();

var tokensTask = FetchEngineTokens(symbols);
var metricsTask = FetchEngineMetrics(symbols);
// Balances are the only required leg: they are the user's actual
// holdings. Tokens (name/precision/icon) and metrics (market price,
// used for fiat valuation) are enrichment — ConvertEngineToken already
// null-tolerates both. Letting either failure propagate would hit the
// catch below and blank the whole layer, so a metrics outage would look
// identical to a total Hive-Engine outage: no tokens in the wallet at
// all. Degrade to unpriced balances instead of showing nothing.
var tokensTask = Optional(FetchEngineTokens(symbols), "engine tokens", EngineEnrichmentTimeoutMs);
var metricsTask = Optional(FetchEngineMetrics(symbols), "engine metrics", EngineEnrichmentTimeoutMs);
// The rewards upstream allows 30s, but this whole fetch must fit
// the portfolioV2 engine leg budget (4.5s) — a slow rewards call
// would otherwise blank the entire engine layer. Rewards are
Expand Down Expand Up @@ -300,6 +333,9 @@ await Task.WhenAll(globalPropsTask, accountTask, marketTask, pointsTask, engineT
private const int FastLegTimeout = 3000;
private const int SlowLegTimeout = 4500;
private const int EngineRewardsTimeoutMs = 2000;
// Same budget as rewards: balances must complete first, then the enrichment
// legs run concurrently, and the whole engine fetch has to fit SlowLegTimeout.
private const int EngineEnrichmentTimeoutMs = 2000;

private static async Task<T> WithTimeout<T>(Task<T> task, int ms, T fallback)
{
Expand Down
Loading