This class manages server connectivity and compatibility. It initializes network state by contacting the backend server to retrieve version metadata and feature capabilities.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Sandbox;
public static partial class NetworkStorage
{
/// <summary>Last server version reported by server-info or a response header; null on older servers.</summary>
public static string ServerVersion { get; private set; }
/// <summary>Last reported library status. Unknown metadata never disables an existing feature.</summary>
public static string LibraryStatus { get; private set; } = "unknown";
/// <summary>API root path the server advertises in server-info (<c>api.root</c>, for example "/api"); null when none.</summary>
public static string ServerApiRoot { get; private set; }
/// <summary>Raised only when the reported status changes. No player-facing UI is created.</summary>
public static event Action<string> OnLibraryStatusChanged;
private static HashSet<string> _serverCapabilities;
private static readonly HashSet<string> _loggedLibraryStatuses = new( StringComparer.Ordinal );
private static int _compatibilityGeneration;
private static string _compatibilityRoot;
private static string _compatibilityProject;
private static string _compatibilityKey;
private static Task _serverInfoTask = Task.CompletedTask;
/// <summary>Whether a feature is available. Servers without capability metadata retain legacy behavior.</summary>
public static bool HasCapability( string name ) => !string.IsNullOrWhiteSpace( name )
&& (_serverCapabilities is null || _serverCapabilities.Contains( name ));
private static void InitializeServerCompatibility()
{
if ( _compatibilityRoot == ApiRoot && _compatibilityProject == ProjectId && _compatibilityKey == ApiKey ) return;
_compatibilityRoot = ApiRoot;
_compatibilityProject = ProjectId;
_compatibilityKey = ApiKey;
ServerVersion = null;
ServerApiRoot = null;
LibraryStatus = "unknown";
_serverCapabilities = null;
var generation = ++_compatibilityGeneration;
_serverInfoTask = ReadServerInfoAsync( BaseUrl, ApiVersion, generation );
}
private static async Task ReadServerInfoAsync( string baseUrl, string apiVersion, int generation )
{
try
{
using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 5 ) );
var headers = new Dictionary<string, string> { ["x-ns-library-version"] = LibraryVersion, ["X-Correlation-ID"] = NewRequestToken() };
var sentAt = DateTimeOffset.UtcNow;
using var response = await Http.RequestAsync( $"{baseUrl}/{apiVersion}/server-info", "GET", null, headers, timeout.Token );
if ( generation == _compatibilityGeneration ) ObserveServerDateHeader( response, sentAt, DateTimeOffset.UtcNow );
if ( generation != _compatibilityGeneration || !response.IsSuccessStatusCode ) return;
var raw = await response.Content.ReadAsStringAsync( timeout.Token );
if ( generation != _compatibilityGeneration ) return;
using var document = JsonDocument.Parse( raw );
var info = document.RootElement;
if ( info.ValueKind != JsonValueKind.Object ) return;
if ( info.TryGetProperty( "version", out var version ) && version.ValueKind == JsonValueKind.String )
ServerVersion = version.GetString();
if ( info.TryGetProperty( "capabilities", out var capabilities ) && capabilities.ValueKind == JsonValueKind.Array )
_serverCapabilities = capabilities.EnumerateArray().Where( c => c.ValueKind == JsonValueKind.String )
.Select( c => c.GetString() ).ToHashSet( StringComparer.Ordinal );
if ( info.TryGetProperty( "api", out var api ) && api.ValueKind == JsonValueKind.Object
&& api.TryGetProperty( "root", out var root ) && root.ValueKind == JsonValueKind.String )
ServerApiRoot = ValidServerApiRoot( root.GetString() );
ObserveCompatibilityHeaders( response );
}
catch ( Exception )
{
// The lookup is optional: offline and older servers keep the existing request path.
}
}
/// <summary>
/// Accepts only a plain absolute path such as "/api": no scheme or host, query, fragment, backslash, control
/// character or dot segment. Anything else is ignored and the library keeps its versioned root.
/// </summary>
private static string ValidServerApiRoot( string path )
{
if ( string.IsNullOrWhiteSpace( path ) || path[0] != '/' || path.Length > 128 ) return null;
if ( path.Contains( "..", StringComparison.Ordinal ) || path.Contains( "//", StringComparison.Ordinal ) ) return null;
if ( path.Any( c => c == '\\' || c == '?' || c == '#' || char.IsControl( c ) ) ) return null;
var trimmed = path.TrimEnd( '/' );
return trimmed.Length == 0 ? null : trimmed;
}
private static void ObserveCompatibilityHeaders( HttpResponseMessage response )
{
string version = null;
string status = null;
if ( response.Headers.TryGetValues( "x-ns-server-version", out var versions ) ) version = versions.FirstOrDefault();
if ( response.Headers.TryGetValues( "x-ns-library-status", out var statuses ) ) status = statuses.FirstOrDefault();
ApplyCompatibilityMetadata( version, status );
}
/// <summary>
/// Applies the values of the <c>x-ns-server-version</c> and <c>x-ns-library-status</c> response headers.
/// Null or unrecognized values change nothing, so servers without these headers behave as before.
/// </summary>
public static void ApplyCompatibilityMetadata( string serverVersion, string libraryStatus )
{
if ( !string.IsNullOrWhiteSpace( serverVersion ) ) ServerVersion = serverVersion.Trim();
if ( libraryStatus is not null ) ObserveLibraryStatus( libraryStatus.Trim().ToLowerInvariant() );
}
/// <summary>
/// Reads the <c>{ ok:false, error:{ code, message } }</c> body of a 426 response. Only
/// LIBRARY_UNSUPPORTED and LIBRARY_BLOCKED are compatibility errors; anything else returns false.
/// </summary>
public static bool TryReadCompatibilityError( string raw, out string code, out string message )
{
code = null;
message = null;
try
{
using var document = JsonDocument.Parse( raw ?? "" );
if ( !document.RootElement.TryGetProperty( "error", out var error ) || error.ValueKind != JsonValueKind.Object ) return false;
var found = error.TryGetProperty( "code", out var c ) && c.ValueKind == JsonValueKind.String ? c.GetString() : null;
if ( found is not ("LIBRARY_UNSUPPORTED" or "LIBRARY_BLOCKED") ) return false;
code = found;
message = error.TryGetProperty( "message", out var m ) && m.ValueKind == JsonValueKind.String ? m.GetString() : "";
return true;
}
catch ( JsonException )
{
return false;
}
}
private static void ObserveLibraryStatus( string status )
{
if ( status is not ("supported" or "outdated" or "deprecated" or "unsupported" or "blocked" or "unknown") ) return;
if ( status == LibraryStatus ) return;
LibraryStatus = status;
if ( _loggedLibraryStatuses.Add( status ) )
NetLog.Info( "compat", $"Library {LibraryVersion}: {status}; server {ServerVersion ?? "unknown"}." );
InvokeSafely( OnLibraryStatusChanged, status, nameof( OnLibraryStatusChanged ) );
}
/// <summary>
/// Compatibility wrapper for callers that need a body or an exception. A reply that is not 2xx throws an
/// <see cref="HttpRequestException"/> with the status. New code uses <see cref="SendRawAsync"/> instead.
/// </summary>
internal static async Task<string> RequestStringAsync( string url, string method = "GET", Func<HttpContent> createContent = null,
Dictionary<string, string> headers = null )
{
var reply = await SendRawContentAsync( url, method, createContent, headers );
if ( reply.TransportError is not null ) throw new HttpRequestException( reply.TransportError );
if ( reply.Ok || IsCompatibilityReply( reply ) ) return reply.Body;
throw new HttpRequestException( $"Response status code does not indicate success: {reply.Status} ({(HttpStatusCode)reply.Status}).", null, (HttpStatusCode)reply.Status );
}
/// <summary>True for a 426 whose body is a library compatibility error (LIBRARY_UNSUPPORTED or LIBRARY_BLOCKED).</summary>
private static bool IsCompatibilityReply( RawReply reply ) => reply.Status == 426 && TryReadCompatibilityError( reply.Body, out _, out _ );
/// <summary>
/// Sends a request and retries transient failures: rate limits (429), and 503 with Retry-After for reads. Content is
/// created for each attempt because the engine disposes request content after it is sent. Every HTTP status comes back
/// as a <see cref="RawReply"/>; only a failure to get any response is reported as a transport error.
/// </summary>
internal static async Task<RawReply> SendWithRetriesAsync( string method, Func<HttpContent> createContent,
Func<HttpContent, Task<HttpResponseMessage>> send )
{
var waited = 0.0;
for ( var retries = 0; ; retries++ )
{
var generation = _compatibilityGeneration;
RawReply reply;
try
{
using var content = createContent?.Invoke();
var sentAt = DateTimeOffset.UtcNow;
using var response = await send( content );
if ( generation == _compatibilityGeneration ) ObserveServerDateHeader( response, sentAt, DateTimeOffset.UtcNow );
if ( generation == _compatibilityGeneration ) ObserveCompatibilityHeaders( response );
var body = await response.Content.ReadAsStringAsync();
reply = new RawReply( (int)response.StatusCode, body, null, RetryAfterHeader( response ) );
}
catch ( Exception ex )
{
return new RawReply( 0, null, ex.Message, null );
}
// A 426 carries the server's remediation message in a structured body. The caller records it as an error.
if ( reply.Status == 426 && TryReadCompatibilityError( reply.Body, out var code, out var message ) )
{
ObserveLibraryStatus( code == "LIBRARY_BLOCKED" ? "blocked" : "unsupported" );
Log.Warning( $"[NetworkStorage] {code}: {message}" );
return reply;
}
// Transient failures retry a bounded number of times; the total wait never exceeds MaxRateLimitWaitSeconds.
var transient = IsRetryableRateLimit( method, reply.Status, reply.Body ) || IsRetryableUnavailable( method, reply.Status, reply.RetryAfter );
if ( retries < MaxRateLimitRetries && transient )
{
var delay = RateLimitDelaySeconds( reply.RetryAfter, reply.Body );
if ( waited + delay <= MaxRateLimitWaitSeconds )
{
waited += delay;
Log.Info( $"[NetworkStorage] {method} HTTP {reply.Status} transient; retry {retries + 1}/{MaxRateLimitRetries} in {delay:0.##}s." );
await Task.Delay( (int)Math.Ceiling( delay * 1000 ) );
continue;
}
}
return reply;
}
}
private static async Task<bool> RequireCapabilityAsync( string capability, string operation )
{
await _serverInfoTask;
if ( HasCapability( capability ) ) return true;
RecordEndpointErrorUnderCode( operation, "CAPABILITY_UNAVAILABLE",
$"Server {ServerVersion ?? "unknown"} does not advertise '{capability}'. Update the server to use this feature." );
return false;
}
}