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
6 changes: 3 additions & 3 deletions src/Agent/CoreSyncServer.Agent.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
Expand Down
4 changes: 2 additions & 2 deletions src/Data/CoreSyncServer.Data.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.13" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions src/Server/CoreSyncServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
</Target>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.13" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.13">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
7 changes: 5 additions & 2 deletions src/Services/AgentOfflineException.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
namespace CoreSyncServer.Services;
namespace CoreSyncServer.Services;

/// <summary>
/// Thrown by <c>AgentProxySyncProvider</c> when no agent connection is registered for the target DataStore,
/// or the RPC call to the connected agent fails. <c>SyncController</c> maps this to HTTP 503.
/// or the RPC call to the connected agent does not complete (timeout, dropped connection).
/// <c>SyncController</c> maps this to HTTP 503 with the <c>agent-unavailable</c> code.
/// An agent that answers with an error raises the derived <see cref="AgentOperationFailedException"/>,
/// mapped to the same 503 with the <c>agent-error</c> code.
/// </summary>
public class AgentOfflineException : Exception
{
Expand Down
24 changes: 24 additions & 0 deletions src/Services/AgentOperationFailedException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace CoreSyncServer.Services;

/// <summary>
/// Thrown by <c>AgentProxySyncProvider</c> when the agent was reached, executed the requested
/// operation and reported that it failed - typically its sync provider rejecting the changes against
/// the on-premise database.
/// </summary>
/// <remarks>
/// Derives from <see cref="AgentOfflineException"/> and carries the very message that type carried
/// for this case, so <c>SyncController</c> keeps answering HTTP 503 with the same body and logging
/// it the same way: a server deployed ahead of its clients must look identical to them. What it adds
/// is the distinction itself, which the controller turns into the <c>agent-error</c> code header so
/// that a current client no longer reads an agent-side failure as "agent offline".
/// </remarks>
public class AgentOperationFailedException : AgentOfflineException
{
public string Operation { get; }

public AgentOperationFailedException(int dataStoreId, string operation, Exception inner)
: base(dataStoreId, $"Agent RPC '{operation}' failed for DataStore {dataStoreId}.", inner)
{
Operation = operation;
}
}
59 changes: 44 additions & 15 deletions src/Services/Controllers/SyncController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,21 @@

namespace CoreSyncServer.Controllers;

/// <summary>
/// The CoreSync HTTP wire protocol, served per published endpoint.
/// </summary>
/// <remarks>
/// Failures the controller recognises are answered through <see cref="SyncErrorResponses"/>: the same
/// status code as before, plus the <c>X-CoreSync-Error</c> / <c>X-CoreSync-Trace</c> headers and a
/// <see cref="SyncErrorDetails"/> body a current client turns into a typed failure. Anything else
/// propagates to the host (exception handler, Sentry) as it always did, tagged with the same headers
/// by <see cref="SyncErrorResponseFilter"/>. No status code differs from before the headers existed.
/// </remarks>
[ApiController]
[AllowAnonymous]
[Route("api/sync/{endpointId:guid}")]
[ServiceFilter(typeof(SyncEndpointAuthFilter))]
[ServiceFilter(typeof(SyncConfigurationExceptionFilter))]
[ServiceFilter(typeof(SyncErrorResponseFilter))]
public class SyncController(
ApplicationDbContext context,
ISyncProviderFactory syncProviderFactory,
Expand Down Expand Up @@ -132,7 +142,7 @@ public async Task<ActionResult<string>> GetStoreId(Guid endpointId, Cancellation
}
catch (KeyNotFoundException)
{
return NotFound();
return EndpointNotFound(endpointId);
}
catch (AgentOfflineException ex)
{
Expand All @@ -151,18 +161,37 @@ public async Task<ActionResult<SyncVersion>> GetSyncVersion(Guid endpointId, Can
}
catch (KeyNotFoundException)
{
return NotFound();
return EndpointNotFound(endpointId);
}
catch (AgentOfflineException ex)
{
return AgentOffline(ex);
}
}

/// <summary>
/// 404 with the <c>endpoint-not-found</c> code: the status an unknown endpoint always produced,
/// now distinguishable from an expired session, which is also a 404.
/// </summary>
private ObjectResult EndpointNotFound(Guid endpointId)
=> SyncErrorResponses.EndpointNotFound(HttpContext, endpointId);

/// <summary>
/// 404 with the <c>session-expired</c> code: the status an unknown upload/download session always
/// produced. The client is told to start a new synchronization rather than retry the request.
/// </summary>
private ObjectResult SessionExpired(Guid sessionId)
=> SyncErrorResponses.SessionExpired(HttpContext, sessionId);

/// <summary>
/// The 503 every agent failure has always produced, logged as it always was: <c>agent-unavailable</c>
/// when the agent owning the data store is not connected or did not answer in time,
/// <c>agent-error</c> when it answered that the operation failed. Both are left retryable.
/// </summary>
private ObjectResult AgentOffline(AgentOfflineException ex)
{
logger.LogWarning(ex, "Agent offline for DataStore {DataStoreId}", ex.DataStoreId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, ex.Message);
return SyncErrorResponses.AgentFailure(HttpContext, ex);
}

[HttpGet("changes-bulk/{storeId:guid}")]
Expand Down Expand Up @@ -215,7 +244,7 @@ public async Task<ActionResult<BulkSyncChangeSet>> GetBulkChangeSet(Guid endpoin
}
catch (KeyNotFoundException)
{
return NotFound();
return EndpointNotFound(endpointId);
}
catch (AgentOfflineException ex)
{
Expand All @@ -242,7 +271,7 @@ public ActionResult<IReadOnlyList<SyncItem>> GetBulkChangeSetItem(Guid sessionId
return Ok(bufferList);
}

return NotFound();
return SessionExpired(sessionId);
}

[HttpGet("changes-bulk-item-binary/{sessionId:guid}/{skip:int}/{take:int}")]
Expand All @@ -265,7 +294,7 @@ public ActionResult GetBulkChangeSetItemBinary(Guid sessionId, int skip, int tak
return File(bytes, "application/x-msgpack");
}

return NotFound();
return SessionExpired(sessionId);
}

[HttpPost("changes-bulk-begin")]
Expand All @@ -288,7 +317,7 @@ public async Task<ActionResult> BeginApplyBulkChanges(Guid endpointId, [FromBody
}
catch (KeyNotFoundException)
{
return NotFound();
return EndpointNotFound(endpointId);
}
}

Expand All @@ -301,7 +330,7 @@ public ActionResult ApplyBulkChangesItem([FromBody] BulkChangeSetUploadItem bulk
return Ok();
}

return NotFound();
return SessionExpired(bulkUploadItem.SessionId);
}

[HttpPost("changes-bulk-item-binary")]
Expand All @@ -319,7 +348,7 @@ public async Task<ActionResult> ApplyBulkChangesItemBinary()
return Ok();
}

return NotFound();
return SessionExpired(bulkUploadItem.SessionId);
}

[HttpPost("changes-bulk-complete/{sessionId:guid}")]
Expand Down Expand Up @@ -360,7 +389,7 @@ public async Task<ActionResult<SyncAnchor>> CompleteApplyBulkChanges(Guid endpoi
// Cleanup on CancellationToken.None — see GetBulkChangeSet for the rationale.
await sessionLogger.FlushAsync(CancellationToken.None);
await syncSessionService.ErrorAsync(cached.SyncSessionId, $"Endpoint '{endpointId}' not found.", CancellationToken.None);
return NotFound();
return EndpointNotFound(endpointId);
}
catch (AgentOfflineException ex)
{
Expand All @@ -376,7 +405,7 @@ public async Task<ActionResult<SyncAnchor>> CompleteApplyBulkChanges(Guid endpoi
}
}

return NotFound();
return SessionExpired(sessionId);
}

[HttpPost("changes-bulk-complete-binary/{sessionId:guid}")]
Expand Down Expand Up @@ -406,7 +435,7 @@ public async Task<ActionResult<SyncAnchor>> CompleteApplyBulkChangesBinary(Guid
// Cleanup on CancellationToken.None — see GetBulkChangeSet for the rationale.
await sessionLogger.FlushAsync(CancellationToken.None);
await syncSessionService.ErrorAsync(cached.SyncSessionId, $"Endpoint '{endpointId}' not found.", CancellationToken.None);
return NotFound();
return EndpointNotFound(endpointId);
}
catch (AgentOfflineException ex)
{
Expand All @@ -422,7 +451,7 @@ public async Task<ActionResult<SyncAnchor>> CompleteApplyBulkChangesBinary(Guid
}
}

return NotFound();
return SessionExpired(sessionId);
}

[HttpPost("save-version/{storeId:guid}/{version:long}")]
Expand All @@ -439,7 +468,7 @@ public async Task<ActionResult> SaveVersionForStore(Guid endpointId, Guid storeI
}
catch (KeyNotFoundException)
{
return NotFound();
return EndpointNotFound(endpointId);
}
catch (AgentOfflineException ex)
{
Expand Down
6 changes: 3 additions & 3 deletions src/Services/CoreSyncServer.Services.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
<PackageReference Include="MessagePack" Version="3.1.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.13" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 6 additions & 0 deletions src/Services/Filters/SyncConfigurationExceptionFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ namespace CoreSyncServer.Filters;
/// 422 Unprocessable Entity response carrying the provider's message, instead of letting them
/// surface to the sync client as an opaque HTTP 500.
/// </summary>
/// <remarks>
/// Superseded by <see cref="SyncErrorResponseFilter"/>, which answers the same 422 with the
/// <c>configuration-error</c> code and a <see cref="CoreSync.Http.SyncErrorDetails"/> body and also
/// tags every other failure. Kept registered so hosts that reference it keep compiling.
/// </remarks>
[Obsolete("Use SyncErrorResponseFilter, which covers this case and every other sync failure.")]
public class SyncConfigurationExceptionFilter(ILogger<SyncConfigurationExceptionFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
Expand Down
12 changes: 4 additions & 8 deletions src/Services/Filters/SyncEndpointAuthFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,11 @@ public async Task OnActionExecutionAsync(ActionExecutingContext filterContext, A
.Include(e => e.Authentication)
.FirstOrDefaultAsync(e => e.Id == endpointId, filterContext.HttpContext.RequestAborted);

if (endpoint is null)
// Same 404 as before, now carrying the endpoint-not-found code and a body: without a body the
// host's status code pages would replace it with the HTML not-found page.
if (endpoint is null || !endpoint.IsPublished)
{
filterContext.Result = new NotFoundResult();
return;
}

if (!endpoint.IsPublished)
{
filterContext.Result = new NotFoundResult();
filterContext.Result = SyncErrorResponses.EndpointNotFound(filterContext.HttpContext, endpointId);
return;
}

Expand Down
48 changes: 48 additions & 0 deletions src/Services/Filters/SyncErrorResponseFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;

namespace CoreSyncServer.Filters;

/// <summary>
/// Makes every failure escaping <c>SyncController</c> classifiable by the client.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><description>
/// <see cref="NotSupportedException"/> - a store configured in a way the provider cannot synchronize
/// (composite primary key, reserved column name...) - becomes 422 Unprocessable Entity with the
/// <c>configuration-error</c> code and a <see cref="CoreSync.Http.SyncErrorDetails"/> body carrying the
/// provider's message, the status this API has always returned for it.
/// </description></item>
/// <item><description>
/// Everything else is left unhandled, so the host's exception handler, logging and error reporting
/// (Sentry) see it exactly as before; only the error code and trace headers are attached to the host's
/// response (see <see cref="SyncErrorResponses.TagUnhandled"/>).
/// </description></item>
/// </list>
/// </remarks>
public class SyncErrorResponseFilter(ILogger<SyncErrorResponseFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.ExceptionHandled)
return;

if (context.Exception is NotSupportedException notSupported)
{
logger.LogWarning(notSupported, "Unsupported sync configuration: {Message}", notSupported.Message);

context.Result = SyncErrorResponses.ConfigurationError(context.HttpContext, notSupported.Message);
context.ExceptionHandled = true;
return;
}

if (context.Exception is OperationCanceledException && context.HttpContext.RequestAborted.IsCancellationRequested)
{
// The client went away; there is no response to tag.
return;
}

SyncErrorResponses.TagUnhandled(context.HttpContext, context.Exception);
}
}
Loading
Loading