Skip to content
Merged
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ docker run -it --rm -p 4000:4000 \
| `HELIUS_API_KEY` | optional Helius API key added as an extra Solana RPC fallback |
| `ETH_RPC_URLS` / `BNB_RPC_URLS` / `SOL_RPC_URLS` / `BTC_ESPLORA_URLS` | optional comma-separated endpoint lists overriding the built-in chain provider pools |
| `Logging__LogLevel__Default` | log level (default `Warning`; set `Information` for per-request logs) |
| `SSR_INTERNAL_SECRET` | shared header secret that switches on the internal SSR RPC cache routes (`/private-api/ssr/*`); unset = they answer like unknown routes |
| `SSR_CACHE_BYTES` | byte budget of the SSR RPC cache, LRU beyond it (default 512 MiB) |
| `SSR_RPC_BUDGET_MS` | wall-clock budget for one SSR RPC lookup before it answers 504 while the fill completes (default `1500`) |
| `SSR_RPC_NODE_TIMEOUT_MS` | per-node timeout of the SSR RPC cache's own client, one attempt per node (default `1200`) |
| `SSR_RPC_NODES` | comma-separated node pool for that client (default: the shared pool) |

## Swarm

Expand Down
5 changes: 5 additions & 0 deletions dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
<ProjectReference Include="../EcencyApi/EcencyApi.csproj" />
</ItemGroup>

<ItemGroup>
<!-- DefaultHttpContext for handler-level tests (SsrRpcTests). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<None Include="fixtures/**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Expand Down
293 changes: 293 additions & 0 deletions dotnet/EcencyApi.Tests/SsrRpcTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using EcencyApi.Handlers;
using EcencyApi.Infrastructure;
using Microsoft.AspNetCore.Http;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// The SSR RPC cache against a loopback stub node: one upstream call per key
/// under concurrency, hits until the TTL runs out, the allowlist and header
/// gates answering like an unknown route, the budget turning a slow upstream
/// into a 504 while the fill still completes, and the byte budget evicting.
/// </summary>
[Collection("ssr-rpc")]
public class SsrRpcTests
{
/// <summary>Loopback JSON-RPC node answering any method with a result that
/// names the method and the hit number, after an optional delay.</summary>
private sealed class RpcStub : IAsyncDisposable
{
private readonly HttpListener _listener = new();
public string Url { get; }
public int Hits;
public int DelayMs;
public bool RpcError;

public RpcStub()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
Url = $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(Url);
_listener.Start();
_ = Loop();
}

private async Task Loop()
{
while (_listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { return; }
var n = Interlocked.Increment(ref Hits);
string reqBody;
using (var reader = new StreamReader(ctx.Request.InputStream))
{
reqBody = await reader.ReadToEndAsync();
}
var method = JsonNode.Parse(reqBody)?["params"]?[1]?.GetValue<string>() ?? "?";
if (DelayMs > 0) await Task.Delay(DelayMs);
var body = RpcError
? "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"stub error\"}}"
: "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"method\":\"" + method + "\",\"n\":" + n + ",\"text\":\"caf\\u00e9 \\ud83d\"}}";
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
try { await ctx.Response.OutputStream.WriteAsync(bytes); ctx.Response.Close(); } catch { }
}
}

public async ValueTask DisposeAsync()
{
_listener.Stop();
_listener.Close();
await Task.CompletedTask;
}
}

private static readonly SsrRpc.MethodPolicy Post = SsrRpc.Allowlist["bridge.get_post"];
private static readonly SsrRpc.MethodPolicy Props = SsrRpc.Allowlist["condenser_api.get_dynamic_global_properties"];

private static void Use(RpcStub stub, long cacheBytes = 1 << 20, int budgetMs = 1500)
{
SsrRpc.Client = new HiveRpcClient(new[] { stub.Url }, timeoutMs: 1000, failoverThreshold: 1);
SsrRpc.Cache = new BytesCache(cacheBytes);
SsrRpc.BudgetMs = budgetMs;
SsrRpc.ResetForTests();
}

private static JsonObject P(string author, string permlink) =>
new() { ["author"] = author, ["permlink"] = permlink };

[Fact]
public async Task Concurrent_misses_for_one_key_make_one_upstream_call()
{
await using var stub = new RpcStub { DelayMs = 200 };
Use(stub);
var tasks = Enumerable.Range(0, 12).Select(_ => SsrRpc.Resolve(Post, P("a", "b"))).ToArray();
var results = await Task.WhenAll(tasks);
Assert.Equal(1, stub.Hits);
Assert.Single(results.Where(r => r.Outcome == SsrRpc.Outcome.Miss));

Check warning on line 98 in dotnet/EcencyApi.Tests/SsrRpcTests.cs

View workflow job for this annotation

GitHub Actions / test

Do not use a Where clause to filter before calling Assert.Single. Use the overload of Assert.Single that accepts a filtering function. (https://xunit.net/xunit.analyzers/rules/xUnit2031)
Assert.Equal(11, results.Count(r => r.Outcome == SsrRpc.Outcome.Coalesced));
var bodies = results.Select(r => Encoding.UTF8.GetString(r.Bytes)).Distinct().ToArray();
Assert.Single(bodies);
Assert.Contains("\"method\":\"get_post\"", bodies[0]);
}

[Fact]
public async Task Second_call_is_a_hit_with_the_same_bytes_and_params_order_does_not_matter()
{
await using var stub = new RpcStub();
Use(stub);
var first = await SsrRpc.Resolve(Post, P("a", "b"));
var reordered = new JsonObject { ["permlink"] = "b", ["author"] = "a" };
var second = await SsrRpc.Resolve(Post, reordered);
Assert.Equal(SsrRpc.Outcome.Miss, first.Outcome);
Assert.Equal(SsrRpc.Outcome.Hit, second.Outcome);
Assert.Equal(first.Bytes, second.Bytes);
Assert.Equal(1, stub.Hits);
// A different post is a different key.
var other = await SsrRpc.Resolve(Post, P("a", "c"));
Assert.Equal(SsrRpc.Outcome.Miss, other.Outcome);
Assert.Equal(2, stub.Hits);
}

[Fact]
public async Task Bytes_are_the_upstream_result_serialized_once_lone_surrogate_included()
{
await using var stub = new RpcStub();
Use(stub);
var r = await SsrRpc.Resolve(Post, P("a", "b"));
var text = Encoding.UTF8.GetString(r.Bytes);
// The result object itself, not the JSON-RPC envelope.
Assert.StartsWith("{\"method\":\"get_post\"", text);
Assert.DoesNotContain("jsonrpc", text);
// JsJson re-emits the lone surrogate as an escape instead of throwing.
Assert.Contains("\\ud83d", text);
}

[Fact]
public async Task Ttl_expiry_goes_upstream_again()
{
await using var stub = new RpcStub();
Use(stub);
var shortLived = Props with { TtlMs = 150 };
Assert.Equal(SsrRpc.Outcome.Miss, (await SsrRpc.Resolve(shortLived, new JsonArray())).Outcome);
Assert.Equal(SsrRpc.Outcome.Hit, (await SsrRpc.Resolve(shortLived, new JsonArray())).Outcome);
await Task.Delay(300);
Assert.Equal(SsrRpc.Outcome.Miss, (await SsrRpc.Resolve(shortLived, new JsonArray())).Outcome);
Assert.Equal(2, stub.Hits);
}

[Fact]
public async Task Budget_exceeded_answers_timeout_while_the_fill_still_lands_in_the_cache()
{
await using var stub = new RpcStub { DelayMs = 400 };
Use(stub, budgetMs: 100);
var r = await SsrRpc.Resolve(Post, P("slow", "post"));
Assert.Equal(SsrRpc.Outcome.Timeout, r.Outcome);
await Task.Delay(600);
SsrRpc.BudgetMs = 1500;
var again = await SsrRpc.Resolve(Post, P("slow", "post"));
Assert.Equal(SsrRpc.Outcome.Hit, again.Outcome);
Assert.Equal(1, stub.Hits);
}

[Fact]
public async Task Rpc_level_error_is_reported_not_cached()
{
await using var stub = new RpcStub { RpcError = true };
Use(stub);
var r = await SsrRpc.Resolve(Post, P("a", "b"));
Assert.Equal(SsrRpc.Outcome.RpcError, r.Outcome);
Assert.Equal("stub error", r.Error);
var again = await SsrRpc.Resolve(Post, P("a", "b"));
Assert.Equal(SsrRpc.Outcome.RpcError, again.Outcome);
Assert.Equal(2, stub.Hits);
}

[Fact]
public async Task Unreachable_node_is_reported_as_unavailable()
{
SsrRpc.Client = new HiveRpcClient(new[] { "http://127.0.0.1:9/" }, timeoutMs: 500, failoverThreshold: 1);
SsrRpc.Cache = new BytesCache(1 << 20);
SsrRpc.BudgetMs = 1500;
SsrRpc.ResetForTests();
var r = await SsrRpc.Resolve(Post, P("a", "b"));
Assert.Equal(SsrRpc.Outcome.Unavailable, r.Outcome);
}

[Fact]
public void Byte_budget_evicts_least_recently_used_and_refuses_oversize()
{
var cache = new BytesCache(100);
cache.Set("a", new byte[40], 60_000);
cache.Set("b", new byte[40], 60_000);
Assert.True(cache.TryGet("a", out _)); // a is now most recent
cache.Set("c", new byte[40], 60_000); // evicts b
Assert.True(cache.TryGet("a", out _));
Assert.False(cache.TryGet("b", out _));
Assert.True(cache.TryGet("c", out _));
Assert.Equal(80, cache.Bytes);
cache.Set("huge", new byte[101], 60_000);
Assert.False(cache.TryGet("huge", out _));
Assert.Equal(2, cache.Count);
}

[Fact]
public async Task Expired_entry_is_dropped_on_read()
{
var cache = new BytesCache(1000);
cache.Set("k", new byte[10], 50);
Assert.True(cache.TryGet("k", out _));
await Task.Delay(120);
Assert.False(cache.TryGet("k", out _));
Assert.Equal(0, cache.Count);
Assert.Equal(0, cache.Bytes);
}

[Fact]
public void Canonical_key_sorts_object_keys_at_every_level_and_keeps_array_order()
{
var a = JsonNode.Parse("{\"z\":1,\"a\":{\"y\":[2,{\"d\":1,\"c\":2}],\"b\":null}}");
var b = JsonNode.Parse("{\"a\":{\"b\":null,\"y\":[2,{\"c\":2,\"d\":1}]},\"z\":1}");
Assert.Equal(SsrRpc.CacheKey(Post, a), SsrRpc.CacheKey(Post, b));
var c = JsonNode.Parse("{\"a\":{\"b\":null,\"y\":[{\"c\":2,\"d\":1},2]},\"z\":1}");
Assert.NotEqual(SsrRpc.CacheKey(Post, a), SsrRpc.CacheKey(Post, c));
}

private static DefaultHttpContext Request(string method, string path, string? header, string? body = null)
{
var ctx = new DefaultHttpContext();
ctx.Request.Method = method;
ctx.Request.Path = path;
if (header != null) ctx.Request.Headers[SsrRpc.HeaderName] = header;
if (body != null)
{
ctx.Request.ContentType = "application/json";
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Request.Body = new MemoryStream(bytes);
ctx.Request.ContentLength = bytes.Length;
}
ctx.Response.Body = new MemoryStream();
return ctx;
}

private static string ResponseText(HttpContext ctx)
{
ctx.Response.Body.Position = 0;
return new StreamReader(ctx.Response.Body).ReadToEnd();
}

[Fact]
public async Task Without_the_secret_configured_both_routes_answer_like_unknown_routes()
{
// SSR_INTERNAL_SECRET is not set in the test environment.
Assert.Null(Config.SsrInternalSecret);
var post = Request("POST", "/private-api/ssr/rpc", "anything", "{\"api\":\"bridge\",\"method\":\"get_post\"}");
await SsrRpc.Rpc(post);
Assert.Equal(404, post.Response.StatusCode);
Assert.Contains("Cannot POST /private-api/ssr/rpc", ResponseText(post));

var get = Request("GET", "/private-api/ssr/stats", "anything");
await SsrRpc.Stats(get);
Assert.Equal(200, get.Response.StatusCode);
Assert.Contains("text/html", get.Response.ContentType);
Assert.DoesNotContain("methods", ResponseText(get));
}

[Fact]
public void Authorized_requires_the_configured_secret_and_a_matching_header()
{
// With no secret configured nothing authorizes, header or not.
Assert.False(SsrRpc.Authorized(Request("POST", "/x", null)));
Assert.False(SsrRpc.Authorized(Request("POST", "/x", "")));
Assert.False(SsrRpc.Authorized(Request("POST", "/x", "guess")));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[Fact]
public void Allowlist_is_read_only_and_names_every_method_the_consumer_routes()
{
foreach (var key in new[]
{
"bridge.get_ranked_posts", "bridge.get_account_posts", "bridge.get_post", "bridge.get_discussion",
"bridge.get_profile", "bridge.get_profiles", "bridge.get_community", "bridge.list_communities",
"condenser_api.get_accounts", "condenser_api.get_content",
"condenser_api.get_dynamic_global_properties", "condenser_api.get_trending_tags",
})
{
Assert.True(SsrRpc.Allowlist.ContainsKey(key), key);
Assert.True(SsrRpc.Allowlist[key].TtlMs > 0, key);
}
Assert.False(SsrRpc.Allowlist.ContainsKey("condenser_api.broadcast_transaction"));
Assert.False(SsrRpc.Allowlist.ContainsKey("database_api.get_accounts"));
}
}
26 changes: 26 additions & 0 deletions dotnet/EcencyApi/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,31 @@ public static class Config
public static string CaptchaMode { get; } =
(Env("CAPTCHA_MODE") ?? "hard").Trim().ToLowerInvariant();

// ---- SSR RPC cache (Handlers/SsrRpc.cs) ----
// Shared secret the web tier sends on every call. Unset = the routes are
// switched off and answer exactly like unknown routes.
public static string? SsrInternalSecret { get; } = NonEmpty(Env("SSR_INTERNAL_SECRET"));

// Total bytes of cached responses kept in memory (LRU beyond that).
public static long SsrCacheBytes { get; } =
long.TryParse(Env("SSR_CACHE_BYTES"), out var b) && b >= 0 ? b : 512L * 1024 * 1024;

// Wall-clock budget for one lookup. The web tier gives up on the proxy a
// little later and falls back to its own node pool, so this must stay
// under that; a lookup that outlives it still completes and fills the cache.
public static int SsrBudgetMs { get; } =
int.TryParse(Env("SSR_RPC_BUDGET_MS"), out var ms) && ms > 0 ? ms : 1500;

// Per-node timeout for the cache's own RPC client (one attempt per node).
public static int SsrNodeTimeoutMs { get; } =
int.TryParse(Env("SSR_RPC_NODE_TIMEOUT_MS"), out var nt) && nt > 0 ? nt : 1200;

// Optional node pool for the cache's RPC client, comma-separated; defaults
// to the shared pool. Lets a deployment put its own node first.
public static string[]? SsrRpcNodes { get; } =
NonEmpty(Env("SSR_RPC_NODES"))?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();

private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
}
Loading
Loading