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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,8 @@ FodyWeavers.xsd

.idea
.idea/*

# Local tool artifacts
.build-timestamp
config.json
local-nuget/
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,103 @@ Turning on *SafeMode* will make the client before the first request to do an int
schema will be used to make sure that any auto included properties are available. This is an advanced feature that
require the endpoint to support introspection. By default safe mode is turned of.

# Error Handling

## Throwing Behavior (Default)

By default, `ExecuteAsync` throws `GraphQueryExecutionException` when the GraphQL response contains errors:

```cs
try
{
var customer = await sampleClient
.Query
.Customer(id: "abc-123")
.Select(e => e)
.ExecuteAsync();
}
catch (GraphQueryExecutionException ex)
{
foreach (var error in ex.Errors)
{
Console.WriteLine($"Error: {error.Message}");
Console.WriteLine($"Code: {error.ErrorCode}");
}
}
catch (GraphQueryRequestException ex)
{
Console.WriteLine($"HTTP error: {ex.Message}");
}
```

`GraphQueryExecutionException` provides:
- **Errors** — list of `GraphQueryError` with `Message`, `Locations`, `Path`, `Extensions`
- **ErrorCode** — classified error code (Authentication, Forbidden, Validation, BadRequest, etc.)
- **Extensions** — full error extensions from the server (custom codes, status codes, etc.)
- **GraphQLQuery** / **GraphQLVariables** — the request that caused the error

## Result API (No Throw)

Use `ExecuteWithResultAsync` to get both data and errors without exceptions:

```cs
var result = await sampleClient
.Query
.Customer(id: "abc-123")
.Select(e => e)
.ExecuteWithResultAsync();

if (result.HasErrors)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"{error.ErrorCode}: {error.Message}");
}
}

if (result.HasData)
{
Console.WriteLine(result.Data.CustomerName);
}
```

`GraphResult<T>` provides:
- **Data** — the response data (may be present even with partial errors)
- **Errors** — list of `GraphQueryError`
- **Extensions** — response-level extensions from the server
- **HasErrors** / **HasData** — quick checks
- **EnsureNoErrors()** — throws if errors exist (opt-in to throwing)

## Error Codes

`GraphQueryError.ErrorCode` classifies known error codes from popular GraphQL servers:

| Code | Enum |
|------|------|
| `UNAUTHENTICATED` | `GraphErrorCode.Authentication` |
| `FORBIDDEN` | `GraphErrorCode.Forbidden` |
| `BAD_USER_INPUT` | `GraphErrorCode.BadRequest` |
| `GRAPHQL_VALIDATION_FAILED` | `GraphErrorCode.Validation` |
| `INTERNAL_SERVER_ERROR` | `GraphErrorCode.InternalServerError` |
| `RATE_LIMITED` | `GraphErrorCode.RateLimited` |
| `TIMEOUT` | `GraphErrorCode.Timeout` |

Unrecognized codes return `GraphErrorCode.Unknown`.

## Cursor Paging

`NextPageWithResultAsync` and `PreviousPageWithResultAsync` follow the same pattern:

```cs
var pager = sampleClient
.Query
.Orders(first: 10)
.AsPager();

var page = await pager.NextPageWithResultAsync();
if (page.HasErrors) { /* handle */ }
```

# Acknowledgments

Linq2GraphQL is inspired by [GraphQLinq](https://github.com/Giorgi/GraphQLinq) , thank
Expand Down
85 changes: 85 additions & 0 deletions docs/error-handling-fix-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Error Handling Fix Plan — Linq2GraphQL.Client

## Overview
Fix 6 GraphQL error handling deficiencies. 4 phases, 12 steps. Each step leaves codebase buildable and backward-compatible.

## Phase 1: Foundation (Issues 1, 6, 3) — Data model changes only

### Step 1.1: Add `Extensions` property to `GraphQueryError`
- **File**: `src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs`
- **Action**: Add `[JsonPropertyName("extensions")] public Dictionary<string, object>? Extensions { get; set; }` to `GraphQueryError`
- **Effort**: 2 min | **Risk**: Low | **BC**: 100%

### Step 1.2: Create `GraphErrorCode` enum + classifier
- **New File**: `src/Linq2GraphQL.Client/Exceptions/GraphErrorCode.cs`
- **Action**: Enum with standard codes (UNAUTHENTICATED, FORBIDDEN, VALIDATION, etc.) + `GraphErrorCodeClassifier` static helper
- **Also**: Add `ErrorCode` computed property to `GraphQueryError`
- **Effort**: 10 min | **Risk**: Low | **BC**: 100%

### Step 1.3: Create `GraphResult<T>` wrapper
- **New File**: `src/Linq2GraphQL.Client/GraphResult.cs`
- **Action**: `GraphResult<T>` with `Data`, `Errors`, `Extensions`, `HasErrors`, `HasData`
- **Effort**: 5 min | **Risk**: Low | **BC**: 100%

## Phase 2: Core Query/Mutation Error Handling (Issues 2, 3)

### Step 2.1: Refactor `QueryExecutor` — internal `ProcessResponseFull`
- **File**: `src/Linq2GraphQL.Client/QueryExecutor.cs`
- **Action**: New `internal GraphResult<T> ProcessResponseFull(...)` that parses data+errors+extensions. Keep existing `ProcessResponse` as backward-compat wrapper.
- **Effort**: 15 min | **Risk**: Medium | **BC**: 100%

### Step 2.2: Add `ExecuteWithResultAsync` to `GraphQueryExecute`
- **File**: `src/Linq2GraphQL.Client/GraphQueryExecute.cs` + `QueryExecutor.cs`
- **Action**: Add `ExecuteRawAsync` to QueryExecutor, add `ExecuteBaseWithResultAsync` + `ExecuteWithResultAsync` to GraphQueryExecute
- **Effort**: 15 min | **Risk**: Medium | **BC**: 100%

### Step 2.3: Add `ExecuteWithResultAsync` to cursor paging
- **File**: `src/Linq2GraphQL.Client/GraphQueryExecute.cs` + `GraphCursorPager.cs`
- **Action**: Same pattern for `GraphCursorQueryExecute` and pager
- **Effort**: 10 min | **Risk**: Low | **BC**: 100%

## Phase 3: Subscription Error Handling (Issues 4, 5)

### Step 3.1: Handle WS `type: "error"` and `type: "complete"` messages
- **Files**: `WebsocketRequestTypes.cs`, `WebsocketResponse.cs`, `WSClient.cs`
- **Action**: Add message type constants, handle error/complete in WSClient message routing, propagate via OnError/OnCompleted
- **Effort**: 25 min | **Risk**: Medium | **BC**: Behavioral fix

### Step 3.2: Add error resilience to subscription pipeline
- **File**: `GraphSubscriptionExecute.cs`
- **Action**: Replace `Select` with `SelectMany` + try/catch — skip bad messages, don't kill stream
- **Effort**: 15 min | **Risk**: Medium | **BC**: Behavioral fix

### Step 3.3: SSE client error handling
- **File**: `SSEClient.cs`
- **Action**: Wrap HTTP errors in `GraphQueryRequestException`, null checks, parse `event: error` SSE frames
- **Effort**: 10 min | **Risk**: Low | **BC**: 100%

## Phase 4: Testing & Verification

### Step 4.1: Add error handling unit tests
- **New File**: `test/Linq2GraphQL.Tests/ErrorHandlingTests.cs`
- **Action**: Test extensions deserialization, GraphResult partial data, error codes, subscription error resilience
- **Effort**: 20 min | **Risk**: Low

### Step 4.2: Run full test suite
- **Action**: `dotnet build` + `dotnet test`
- **Effort**: 5 min

## Dependency Graph
```
1.1 → 1.2 → 1.3 → 2.1 → 2.2 → 2.3
1.1 → 3.1 → 3.2
↘ 3.3
→ 4.1 → 4.2
```

## Risk Summary
| Step | Risk | Mitigation |
|------|------|------------|
| 1.1-1.3 | Low | Additive only, no behavioral change |
| 2.1-2.3 | Medium | Keep old APIs, add new opt-in APIs |
| 3.1-3.2 | Medium | Test with both WS protocols |
| 3.3 | Low | Defensive coding |
| 4.1-4.2 | Low | Tests are additive |
26 changes: 23 additions & 3 deletions src/Linq2GraphQL.Client.Subscriptions/GraphSubscriptionExecute.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Linq.Expressions;
using System.Diagnostics;
using System.Linq.Expressions;
using System.Reactive.Linq;

namespace Linq2GraphQL.Client.Subscriptions;
Expand All @@ -23,11 +24,30 @@ public async Task<IObservable<TResult>> StartAsync()
#pragma warning disable CS4014
Task.Run(sseClient.Start);
#pragma warning restore CS4014
return sseClient.Subscription.Select(e => ConvertResult(queryExecutor.ProcessResponse(e, QueryNode.Name, request)));
return sseClient.Subscription.SelectMany(json => SafeProcessMessage(json, request));
}

var wsClient = new WSClient(client, request);
await wsClient.Start();
return wsClient.Subscription.Select(e => ConvertResult(queryExecutor.ProcessResponse(e, QueryNode.Name, request)));
return wsClient.Subscription.SelectMany(json => SafeProcessMessage(json, request));
}

private IEnumerable<TResult> SafeProcessMessage(string json, GraphQLRequest request)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<TResult>();
}

try
{
var result = queryExecutor.ProcessResponse(json, QueryNode.Name, request);
return new[] { ConvertResult(result) };
}
catch (Exception ex)
{
Debug.WriteLine($"Subscription message error: {ex.Message}");
return Array.Empty<TResult>();
}
}
}
37 changes: 34 additions & 3 deletions src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Net.Http.Headers;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Reactive.Linq;
using System.Reactive.Subjects;
Expand All @@ -12,6 +12,7 @@ public class SSEClient : IDisposable
private readonly GraphClient graphClient;
private readonly GraphQLRequest payload;
private readonly Subject<string> subscriptionSubject = new();
private readonly Subject<GraphQueryExecutionException> errorSubject = new();
private HttpResponseMessage response;
private StreamReader streamReader;

Expand All @@ -22,6 +23,7 @@ public SSEClient(GraphClient graphClient, GraphQLRequest payload)
}

public IObservable<string> Subscription => subscriptionSubject.AsObservable();
public IObservable<GraphQueryExecutionException> Errors => errorSubject.AsObservable();

public void Dispose()
{
Expand All @@ -39,20 +41,49 @@ public async Task Start()
};

request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
response = await graphClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();

try
{
response = await graphClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
}
catch (HttpRequestException ex)
{
throw new GraphQueryRequestException(
$"SSE connection failed: {ex.Message}",
payload.Query, payload.Variables);
}

if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
throw new GraphQueryRequestException(
$"SSE connection failed with status {response.StatusCode}: {content}",
payload.Query, payload.Variables);
}

streamReader = new StreamReader(await response.Content.ReadAsStreamAsync());

while (!streamReader.EndOfStream)
{
var message = await streamReader.ReadLineAsync();

if (message == null) continue;

if (message.StartsWith("data: "))
{
var jsonData = message.Substring(6);
subscriptionSubject.OnNext(jsonData);
}
else if (message.StartsWith("event: error"))
{
var errorData = await streamReader.ReadLineAsync();
if (errorData != null && errorData.StartsWith("data: "))
{
var errorJson = errorData.Substring(6);
var errors = new List<GraphQueryError> { new() { Message = errorJson } };
errorSubject.OnNext(new GraphQueryExecutionException(errors, payload.Query, payload.Variables));
}
}
}
}
}
29 changes: 27 additions & 2 deletions src/Linq2GraphQL.Client.Subscriptions/WSClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Net.WebSockets;
using System.Reactive.Linq;
using System.Reactive.Subjects;
Expand All @@ -14,6 +14,7 @@ public class WSClient : IAsyncDisposable
private readonly GraphQLRequest payload;

private readonly Subject<string> subscriptionSubject = new();
private readonly Subject<GraphQueryExecutionException> errorSubject = new();
private readonly WebsocketClient client;

private readonly JsonSerializerOptions jsonOptions;
Expand Down Expand Up @@ -41,6 +42,7 @@ public WSClient(GraphClient graphClient, GraphQLRequest payload)
}

public IObservable<string> Subscription => subscriptionSubject.AsObservable();
public IObservable<GraphQueryExecutionException> Errors => errorSubject.AsObservable();


public async ValueTask DisposeAsync()
Expand All @@ -67,7 +69,30 @@ public async Task Start()

tt.Where(e => e.Type == WebsocketRequestTypes.PING).Subscribe(msg => SendRequest(new WebsocketRequest(WebsocketRequestTypes.PONG)));

tt.Where(e => !string.IsNullOrEmpty(e?.Id)).Subscribe(r =>
tt.Where(e => e.Type == WebsocketRequestTypes.ERROR).Subscribe(r =>
{
LogMessage($"Subscription error received: {r.Payload}");
var errors = r.Payload is JsonElement payloadEl && payloadEl.ValueKind == JsonValueKind.Object
&& payloadEl.TryGetProperty("errors", out var errorsEl)
? errorsEl.Deserialize<List<GraphQueryError>>(_graphClient.SerializerOptions)
: new List<GraphQueryError> { new() { Message = r.Payload?.ToString() ?? "Unknown subscription error" } };
errorSubject.OnNext(new GraphQueryExecutionException(errors, string.Empty, null));
});

tt.Where(e => e.Type == WebsocketRequestTypes.COMPLETE).Subscribe(r =>
{
LogMessage($"Subscription completed for id: {r.Id}");
subscriptionSubject.OnCompleted();
errorSubject.OnCompleted();
});

tt.Where(e => !string.IsNullOrEmpty(e?.Id)
&& e.Type != WebsocketRequestTypes.ERROR
&& e.Type != WebsocketRequestTypes.COMPLETE
&& e.Type != WebsocketRequestTypes.PING
&& e.Type != WebsocketRequestTypes.PONG
&& e.Type != WebsocketRequestTypes.CONNECTION_ACK
&& e.Type != WebsocketRequestTypes.CONNECTION_INIT).Subscribe(r =>
{
subscriptionSubject.OnNext(r.Payload?.ToString());
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
namespace Linq2GraphQL.Client.Subscriptions
namespace Linq2GraphQL.Client.Subscriptions
{
internal class WebsocketRequestTypes
{
internal const string PING = "ping";
internal const string PONG = "pong";
internal const string CONNECTION_INIT = "connection_init";

internal const string CONNECTION_ACK = "connection_ack";
internal const string ERROR = "error";
internal const string COMPLETE = "complete";
}
}
6 changes: 4 additions & 2 deletions src/Linq2GraphQL.Client/Assembly.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Linq2GraphQL.Generator")]
[assembly: InternalsVisibleTo("Linq2GraphQL.Generator")]
[assembly: InternalsVisibleTo("Linq2GraphQL.Tests")]
[assembly: InternalsVisibleTo("Linq2GraphQL.Client.Subscriptions")]
Loading
Loading