mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: enhance server registration handling with response envelope and error management
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
@@ -280,8 +281,8 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
// ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
|
// ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
|
||||||
var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
|
var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
|
||||||
|
|
||||||
var result = await _connection.InvokeAsync<RegisterServerResult>("RegisterServer", dto);
|
var envelope = await _connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto);
|
||||||
await HandleRegistrationResultAsync(result, userCount, name, hosts);
|
await HandleRegistrationResponseAsync(envelope, userCount, name, hosts);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -289,18 +290,73 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleRegistrationResultAsync(RegisterServerResult result, int userCount, string name, string[] hosts)
|
private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? envelope, int userCount, string name, string[] hosts)
|
||||||
{
|
{
|
||||||
if (!result.Success)
|
if (envelope is null)
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
_logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted.");
|
||||||
|
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated.
|
||||||
|
if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " +
|
||||||
|
"Refusing to operate. Coordinate a deploy that aligns both sides.",
|
||||||
|
DirectoryProtocol.Version, envelope.Version ?? "(null)");
|
||||||
|
_claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelope.IsSuccess)
|
||||||
|
{
|
||||||
|
await HandleRegistrationErrorAsync(envelope.Errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Data is null)
|
||||||
|
{
|
||||||
|
_registrationPermanentlyFailed = true;
|
||||||
|
_logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted.");
|
||||||
|
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = envelope.Data;
|
||||||
|
var serverId = data.ServerId;
|
||||||
|
|
||||||
|
// Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim.
|
||||||
|
if (!string.IsNullOrEmpty(data.ClaimToken))
|
||||||
|
{
|
||||||
|
await _claimStore.SaveClaimAsync(data.ClaimToken, serverId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
|
||||||
|
await _claimStore.UpdateServerIdAsync(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_claimStore.SetSuccess(serverId);
|
||||||
|
_lastReportedUserCount = userCount;
|
||||||
|
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
|
||||||
{
|
{
|
||||||
_registrationPermanentlyFailed = true;
|
_registrationPermanentlyFailed = true;
|
||||||
|
|
||||||
var error = result.Error ?? "UnknownError";
|
var firstError = errors is { Length: > 0 } ? errors[0] : null;
|
||||||
var conflicts = result.ConflictingHosts is { Length: > 0 }
|
var code = firstError?.Code ?? "UnknownError";
|
||||||
? string.Join(", ", result.ConflictingHosts)
|
var conflictingHosts = ExtractConflictingHosts(firstError);
|
||||||
|
var conflicts = conflictingHosts is { Length: > 0 }
|
||||||
|
? string.Join(", ", conflictingHosts)
|
||||||
: "(none reported)";
|
: "(none reported)";
|
||||||
|
|
||||||
switch (error)
|
switch (code)
|
||||||
{
|
{
|
||||||
case DirectoryRegistrationErrors.HostAlreadyClaimed:
|
case DirectoryRegistrationErrors.HostAlreadyClaimed:
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
@@ -323,40 +379,48 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
conflicts);
|
conflicts);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case DirectoryRegistrationErrors.InvalidInput:
|
||||||
|
_logger.LogError(
|
||||||
|
"Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.",
|
||||||
|
firstError?.Message ?? "(no message)");
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
_logger.LogError("Directory rejected registration with unknown error code: {Error}. Server will not retry until restarted.", error);
|
_logger.LogError(
|
||||||
|
"Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.",
|
||||||
|
code, firstError?.Message ?? "(no message)");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_claimStore.SetFailure(error, result.ConflictingHosts);
|
_claimStore.SetFailure(code, conflictingHosts);
|
||||||
return;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success path
|
/// <summary>
|
||||||
if (!result.ServerId.HasValue)
|
/// Pulls <c>ConflictingHosts</c> out of an error's loosely-typed <c>Data</c> payload.
|
||||||
|
/// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on
|
||||||
|
/// the hub's serializer config and the field is typed <c>object?</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string[]? ExtractConflictingHosts(ErrorDetail? error)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Directory registration succeeded but ServerId was missing — treating as failure to be safe.");
|
if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object)
|
||||||
_registrationPermanentlyFailed = true;
|
return null;
|
||||||
_claimStore.SetFailure("MissingServerId", null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverId = result.ServerId.Value;
|
if (!element.TryGetProperty("ConflictingHosts", out var hostsProp)
|
||||||
|
&& !element.TryGetProperty("conflictingHosts", out hostsProp))
|
||||||
|
return null;
|
||||||
|
|
||||||
// Persist a freshly-issued claim token *before* anything else acks success — this is our durability guarantee.
|
if (hostsProp.ValueKind != JsonValueKind.Array)
|
||||||
if (!string.IsNullOrEmpty(result.ClaimToken))
|
return null;
|
||||||
|
|
||||||
|
List<string> hosts = [];
|
||||||
|
foreach (var item in hostsProp.EnumerateArray())
|
||||||
{
|
{
|
||||||
await _claimStore.SaveClaimAsync(result.ClaimToken, serverId);
|
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
|
||||||
}
|
hosts.Add(s);
|
||||||
else
|
|
||||||
{
|
|
||||||
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
|
|
||||||
await _claimStore.UpdateServerIdAsync(serverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_claimStore.SetSuccess(serverId);
|
return hosts.Count == 0 ? null : hosts.ToArray();
|
||||||
_lastReportedUserCount = userCount;
|
|
||||||
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveVersion()
|
private static string ResolveVersion()
|
||||||
@@ -414,16 +478,35 @@ internal record RegisterServerDto(
|
|||||||
string[] Tags,
|
string[] Tags,
|
||||||
string? ClaimToken);
|
string? ClaimToken);
|
||||||
|
|
||||||
internal record RegisterServerResult(
|
internal record RegisterServerResult(Guid ServerId, string? ClaimToken);
|
||||||
bool Success,
|
|
||||||
Guid? ServerId,
|
/// <summary>
|
||||||
string? ClaimToken,
|
/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract.
|
||||||
string? Error,
|
/// </summary>
|
||||||
string[]? ConflictingHosts);
|
internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error entry inside a <see cref="Response{T}"/>. <c>Data</c> is loosely-typed because the
|
||||||
|
/// payload shape varies by error code (e.g. <c>{ ConflictingHosts: string[] }</c> for host errors).
|
||||||
|
/// </summary>
|
||||||
|
internal record ErrorDetail(string Code, string? Message, JsonElement? Data);
|
||||||
|
|
||||||
|
internal static class DirectoryProtocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pinned envelope protocol version. Bumps are coordinated across both repos.
|
||||||
|
/// </summary>
|
||||||
|
public const string Version = "1.0";
|
||||||
|
}
|
||||||
|
|
||||||
internal static class DirectoryRegistrationErrors
|
internal static class DirectoryRegistrationErrors
|
||||||
{
|
{
|
||||||
public const string HostAlreadyClaimed = "HostAlreadyClaimed";
|
public const string InvalidInput = "InvalidInput";
|
||||||
public const string InvalidToken = "InvalidToken";
|
public const string InvalidToken = "InvalidToken";
|
||||||
|
public const string HostAlreadyClaimed = "HostAlreadyClaimed";
|
||||||
public const string HostConflict = "HostConflict";
|
public const string HostConflict = "HostConflict";
|
||||||
|
|
||||||
|
// Client-side synthetic codes (never returned by hub, generated locally for status reporting)
|
||||||
|
public const string ProtocolVersionMismatch = "ProtocolVersionMismatch";
|
||||||
|
public const string MalformedResponse = "MalformedResponse";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user