Skip to content

Commit c06fb05

Browse files
authored
Merge pull request #88 from ricred/feature/graphql-error-handling
Feature/graphql error handling
2 parents bcee0cc + 2fec72b commit c06fb05

18 files changed

Lines changed: 789 additions & 28 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,8 @@ FodyWeavers.xsd
364364

365365
.idea
366366
.idea/*
367+
368+
# Local tool artifacts
369+
.build-timestamp
370+
config.json
371+
local-nuget/

README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,103 @@ Turning on *SafeMode* will make the client before the first request to do an int
112112
schema will be used to make sure that any auto included properties are available. This is an advanced feature that
113113
require the endpoint to support introspection. By default safe mode is turned of.
114114

115+
# Error Handling
116+
117+
## Throwing Behavior (Default)
118+
119+
By default, `ExecuteAsync` throws `GraphQueryExecutionException` when the GraphQL response contains errors:
120+
121+
```cs
122+
try
123+
{
124+
var customer = await sampleClient
125+
.Query
126+
.Customer(id: "abc-123")
127+
.Select(e => e)
128+
.ExecuteAsync();
129+
}
130+
catch (GraphQueryExecutionException ex)
131+
{
132+
foreach (var error in ex.Errors)
133+
{
134+
Console.WriteLine($"Error: {error.Message}");
135+
Console.WriteLine($"Code: {error.ErrorCode}");
136+
}
137+
}
138+
catch (GraphQueryRequestException ex)
139+
{
140+
Console.WriteLine($"HTTP error: {ex.Message}");
141+
}
142+
```
143+
144+
`GraphQueryExecutionException` provides:
145+
- **Errors** — list of `GraphQueryError` with `Message`, `Locations`, `Path`, `Extensions`
146+
- **ErrorCode** — classified error code (Authentication, Forbidden, Validation, BadRequest, etc.)
147+
- **Extensions** — full error extensions from the server (custom codes, status codes, etc.)
148+
- **GraphQLQuery** / **GraphQLVariables** — the request that caused the error
149+
150+
## Result API (No Throw)
151+
152+
Use `ExecuteWithResultAsync` to get both data and errors without exceptions:
153+
154+
```cs
155+
var result = await sampleClient
156+
.Query
157+
.Customer(id: "abc-123")
158+
.Select(e => e)
159+
.ExecuteWithResultAsync();
160+
161+
if (result.HasErrors)
162+
{
163+
foreach (var error in result.Errors)
164+
{
165+
Console.WriteLine($"{error.ErrorCode}: {error.Message}");
166+
}
167+
}
168+
169+
if (result.HasData)
170+
{
171+
Console.WriteLine(result.Data.CustomerName);
172+
}
173+
```
174+
175+
`GraphResult<T>` provides:
176+
- **Data** — the response data (may be present even with partial errors)
177+
- **Errors** — list of `GraphQueryError`
178+
- **Extensions** — response-level extensions from the server
179+
- **HasErrors** / **HasData** — quick checks
180+
- **EnsureNoErrors()** — throws if errors exist (opt-in to throwing)
181+
182+
## Error Codes
183+
184+
`GraphQueryError.ErrorCode` classifies known error codes from popular GraphQL servers:
185+
186+
| Code | Enum |
187+
|------|------|
188+
| `UNAUTHENTICATED` | `GraphErrorCode.Authentication` |
189+
| `FORBIDDEN` | `GraphErrorCode.Forbidden` |
190+
| `BAD_USER_INPUT` | `GraphErrorCode.BadRequest` |
191+
| `GRAPHQL_VALIDATION_FAILED` | `GraphErrorCode.Validation` |
192+
| `INTERNAL_SERVER_ERROR` | `GraphErrorCode.InternalServerError` |
193+
| `RATE_LIMITED` | `GraphErrorCode.RateLimited` |
194+
| `TIMEOUT` | `GraphErrorCode.Timeout` |
195+
196+
Unrecognized codes return `GraphErrorCode.Unknown`.
197+
198+
## Cursor Paging
199+
200+
`NextPageWithResultAsync` and `PreviousPageWithResultAsync` follow the same pattern:
201+
202+
```cs
203+
var pager = sampleClient
204+
.Query
205+
.Orders(first: 10)
206+
.AsPager();
207+
208+
var page = await pager.NextPageWithResultAsync();
209+
if (page.HasErrors) { /* handle */ }
210+
```
211+
115212
# Acknowledgments
116213

117214
Linq2GraphQL is inspired by [GraphQLinq](https://github.com/Giorgi/GraphQLinq) , thank

docs/error-handling-fix-plan.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Error Handling Fix Plan — Linq2GraphQL.Client
2+
3+
## Overview
4+
Fix 6 GraphQL error handling deficiencies. 4 phases, 12 steps. Each step leaves codebase buildable and backward-compatible.
5+
6+
## Phase 1: Foundation (Issues 1, 6, 3) — Data model changes only
7+
8+
### Step 1.1: Add `Extensions` property to `GraphQueryError`
9+
- **File**: `src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs`
10+
- **Action**: Add `[JsonPropertyName("extensions")] public Dictionary<string, object>? Extensions { get; set; }` to `GraphQueryError`
11+
- **Effort**: 2 min | **Risk**: Low | **BC**: 100%
12+
13+
### Step 1.2: Create `GraphErrorCode` enum + classifier
14+
- **New File**: `src/Linq2GraphQL.Client/Exceptions/GraphErrorCode.cs`
15+
- **Action**: Enum with standard codes (UNAUTHENTICATED, FORBIDDEN, VALIDATION, etc.) + `GraphErrorCodeClassifier` static helper
16+
- **Also**: Add `ErrorCode` computed property to `GraphQueryError`
17+
- **Effort**: 10 min | **Risk**: Low | **BC**: 100%
18+
19+
### Step 1.3: Create `GraphResult<T>` wrapper
20+
- **New File**: `src/Linq2GraphQL.Client/GraphResult.cs`
21+
- **Action**: `GraphResult<T>` with `Data`, `Errors`, `Extensions`, `HasErrors`, `HasData`
22+
- **Effort**: 5 min | **Risk**: Low | **BC**: 100%
23+
24+
## Phase 2: Core Query/Mutation Error Handling (Issues 2, 3)
25+
26+
### Step 2.1: Refactor `QueryExecutor` — internal `ProcessResponseFull`
27+
- **File**: `src/Linq2GraphQL.Client/QueryExecutor.cs`
28+
- **Action**: New `internal GraphResult<T> ProcessResponseFull(...)` that parses data+errors+extensions. Keep existing `ProcessResponse` as backward-compat wrapper.
29+
- **Effort**: 15 min | **Risk**: Medium | **BC**: 100%
30+
31+
### Step 2.2: Add `ExecuteWithResultAsync` to `GraphQueryExecute`
32+
- **File**: `src/Linq2GraphQL.Client/GraphQueryExecute.cs` + `QueryExecutor.cs`
33+
- **Action**: Add `ExecuteRawAsync` to QueryExecutor, add `ExecuteBaseWithResultAsync` + `ExecuteWithResultAsync` to GraphQueryExecute
34+
- **Effort**: 15 min | **Risk**: Medium | **BC**: 100%
35+
36+
### Step 2.3: Add `ExecuteWithResultAsync` to cursor paging
37+
- **File**: `src/Linq2GraphQL.Client/GraphQueryExecute.cs` + `GraphCursorPager.cs`
38+
- **Action**: Same pattern for `GraphCursorQueryExecute` and pager
39+
- **Effort**: 10 min | **Risk**: Low | **BC**: 100%
40+
41+
## Phase 3: Subscription Error Handling (Issues 4, 5)
42+
43+
### Step 3.1: Handle WS `type: "error"` and `type: "complete"` messages
44+
- **Files**: `WebsocketRequestTypes.cs`, `WebsocketResponse.cs`, `WSClient.cs`
45+
- **Action**: Add message type constants, handle error/complete in WSClient message routing, propagate via OnError/OnCompleted
46+
- **Effort**: 25 min | **Risk**: Medium | **BC**: Behavioral fix
47+
48+
### Step 3.2: Add error resilience to subscription pipeline
49+
- **File**: `GraphSubscriptionExecute.cs`
50+
- **Action**: Replace `Select` with `SelectMany` + try/catch — skip bad messages, don't kill stream
51+
- **Effort**: 15 min | **Risk**: Medium | **BC**: Behavioral fix
52+
53+
### Step 3.3: SSE client error handling
54+
- **File**: `SSEClient.cs`
55+
- **Action**: Wrap HTTP errors in `GraphQueryRequestException`, null checks, parse `event: error` SSE frames
56+
- **Effort**: 10 min | **Risk**: Low | **BC**: 100%
57+
58+
## Phase 4: Testing & Verification
59+
60+
### Step 4.1: Add error handling unit tests
61+
- **New File**: `test/Linq2GraphQL.Tests/ErrorHandlingTests.cs`
62+
- **Action**: Test extensions deserialization, GraphResult partial data, error codes, subscription error resilience
63+
- **Effort**: 20 min | **Risk**: Low
64+
65+
### Step 4.2: Run full test suite
66+
- **Action**: `dotnet build` + `dotnet test`
67+
- **Effort**: 5 min
68+
69+
## Dependency Graph
70+
```
71+
1.1 → 1.2 → 1.3 → 2.1 → 2.2 → 2.3
72+
73+
1.1 → 3.1 → 3.2
74+
↘ 3.3
75+
→ 4.1 → 4.2
76+
```
77+
78+
## Risk Summary
79+
| Step | Risk | Mitigation |
80+
|------|------|------------|
81+
| 1.1-1.3 | Low | Additive only, no behavioral change |
82+
| 2.1-2.3 | Medium | Keep old APIs, add new opt-in APIs |
83+
| 3.1-3.2 | Medium | Test with both WS protocols |
84+
| 3.3 | Low | Defensive coding |
85+
| 4.1-4.2 | Low | Tests are additive |

src/Linq2GraphQL.Client.Subscriptions/GraphSubscriptionExecute.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Linq.Expressions;
1+
using System.Diagnostics;
2+
using System.Linq.Expressions;
23
using System.Reactive.Linq;
34

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

2930
var wsClient = new WSClient(client, request);
3031
await wsClient.Start();
31-
return wsClient.Subscription.Select(e => ConvertResult(queryExecutor.ProcessResponse(e, QueryNode.Name, request)));
32+
return wsClient.Subscription.SelectMany(json => SafeProcessMessage(json, request));
33+
}
34+
35+
private IEnumerable<TResult> SafeProcessMessage(string json, GraphQLRequest request)
36+
{
37+
if (string.IsNullOrWhiteSpace(json))
38+
{
39+
return Array.Empty<TResult>();
40+
}
41+
42+
try
43+
{
44+
var result = queryExecutor.ProcessResponse(json, QueryNode.Name, request);
45+
return new[] { ConvertResult(result) };
46+
}
47+
catch (Exception ex)
48+
{
49+
Debug.WriteLine($"Subscription message error: {ex.Message}");
50+
return Array.Empty<TResult>();
51+
}
3252
}
3353
}

src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Net.Http.Headers;
1+
using System.Net.Http.Headers;
22
using System.Net.Mime;
33
using System.Reactive.Linq;
44
using System.Reactive.Subjects;
@@ -12,6 +12,7 @@ public class SSEClient : IDisposable
1212
private readonly GraphClient graphClient;
1313
private readonly GraphQLRequest payload;
1414
private readonly Subject<string> subscriptionSubject = new();
15+
private readonly Subject<GraphQueryExecutionException> errorSubject = new();
1516
private HttpResponseMessage response;
1617
private StreamReader streamReader;
1718

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

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

2628
public void Dispose()
2729
{
@@ -39,20 +41,49 @@ public async Task Start()
3941
};
4042

4143
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
42-
response = await graphClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
43-
response.EnsureSuccessStatusCode();
44+
45+
try
46+
{
47+
response = await graphClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
48+
}
49+
catch (HttpRequestException ex)
50+
{
51+
throw new GraphQueryRequestException(
52+
$"SSE connection failed: {ex.Message}",
53+
payload.Query, payload.Variables);
54+
}
55+
56+
if (!response.IsSuccessStatusCode)
57+
{
58+
var content = await response.Content.ReadAsStringAsync();
59+
throw new GraphQueryRequestException(
60+
$"SSE connection failed with status {response.StatusCode}: {content}",
61+
payload.Query, payload.Variables);
62+
}
4463

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

4766
while (!streamReader.EndOfStream)
4867
{
4968
var message = await streamReader.ReadLineAsync();
5069

70+
if (message == null) continue;
71+
5172
if (message.StartsWith("data: "))
5273
{
5374
var jsonData = message.Substring(6);
5475
subscriptionSubject.OnNext(jsonData);
5576
}
77+
else if (message.StartsWith("event: error"))
78+
{
79+
var errorData = await streamReader.ReadLineAsync();
80+
if (errorData != null && errorData.StartsWith("data: "))
81+
{
82+
var errorJson = errorData.Substring(6);
83+
var errors = new List<GraphQueryError> { new() { Message = errorJson } };
84+
errorSubject.OnNext(new GraphQueryExecutionException(errors, payload.Query, payload.Variables));
85+
}
86+
}
5687
}
5788
}
5889
}

src/Linq2GraphQL.Client.Subscriptions/WSClient.cs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Diagnostics;
1+
using System.Diagnostics;
22
using System.Net.WebSockets;
33
using System.Reactive.Linq;
44
using System.Reactive.Subjects;
@@ -14,6 +14,7 @@ public class WSClient : IAsyncDisposable
1414
private readonly GraphQLRequest payload;
1515

1616
private readonly Subject<string> subscriptionSubject = new();
17+
private readonly Subject<GraphQueryExecutionException> errorSubject = new();
1718
private readonly WebsocketClient client;
1819

1920
private readonly JsonSerializerOptions jsonOptions;
@@ -41,6 +42,7 @@ public WSClient(GraphClient graphClient, GraphQLRequest payload)
4142
}
4243

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

4547

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

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

70-
tt.Where(e => !string.IsNullOrEmpty(e?.Id)).Subscribe(r =>
72+
tt.Where(e => e.Type == WebsocketRequestTypes.ERROR).Subscribe(r =>
73+
{
74+
LogMessage($"Subscription error received: {r.Payload}");
75+
var errors = r.Payload is JsonElement payloadEl && payloadEl.ValueKind == JsonValueKind.Object
76+
&& payloadEl.TryGetProperty("errors", out var errorsEl)
77+
? errorsEl.Deserialize<List<GraphQueryError>>(_graphClient.SerializerOptions)
78+
: new List<GraphQueryError> { new() { Message = r.Payload?.ToString() ?? "Unknown subscription error" } };
79+
errorSubject.OnNext(new GraphQueryExecutionException(errors, string.Empty, null));
80+
});
81+
82+
tt.Where(e => e.Type == WebsocketRequestTypes.COMPLETE).Subscribe(r =>
83+
{
84+
LogMessage($"Subscription completed for id: {r.Id}");
85+
subscriptionSubject.OnCompleted();
86+
errorSubject.OnCompleted();
87+
});
88+
89+
tt.Where(e => !string.IsNullOrEmpty(e?.Id)
90+
&& e.Type != WebsocketRequestTypes.ERROR
91+
&& e.Type != WebsocketRequestTypes.COMPLETE
92+
&& e.Type != WebsocketRequestTypes.PING
93+
&& e.Type != WebsocketRequestTypes.PONG
94+
&& e.Type != WebsocketRequestTypes.CONNECTION_ACK
95+
&& e.Type != WebsocketRequestTypes.CONNECTION_INIT).Subscribe(r =>
7196
{
7297
subscriptionSubject.OnNext(r.Payload?.ToString());
7398
});
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
namespace Linq2GraphQL.Client.Subscriptions
1+
namespace Linq2GraphQL.Client.Subscriptions
22
{
33
internal class WebsocketRequestTypes
44
{
55
internal const string PING = "ping";
66
internal const string PONG = "pong";
77
internal const string CONNECTION_INIT = "connection_init";
8-
8+
internal const string CONNECTION_ACK = "connection_ack";
9+
internal const string ERROR = "error";
10+
internal const string COMPLETE = "complete";
911
}
1012
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
using System.Runtime.CompilerServices;
1+
using System.Runtime.CompilerServices;
22

3-
[assembly: InternalsVisibleTo("Linq2GraphQL.Generator")]
3+
[assembly: InternalsVisibleTo("Linq2GraphQL.Generator")]
4+
[assembly: InternalsVisibleTo("Linq2GraphQL.Tests")]
5+
[assembly: InternalsVisibleTo("Linq2GraphQL.Client.Subscriptions")]

0 commit comments

Comments
 (0)