Skip to content

Commit 6f742dc

Browse files
committed
Comments + codersdk.Response handling
1 parent 585418c commit 6f742dc

File tree

3 files changed

+116
-18
lines changed

3 files changed

+116
-18
lines changed

App/Services/CredentialManager.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ public interface ICredentialBackend
5353
}
5454

5555
/// <summary>
56-
/// Implements ICredentialManager using the Windows Credential Manager to
57-
/// store credentials.
56+
/// Implements ICredentialManager using an ICredentialBackend to store
57+
/// credentials.
5858
/// </summary>
5959
public class CredentialManager : ICredentialManager
6060
{
6161
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
6262

6363
// _opLock is held for the full duration of SetCredentials, and partially
64-
// during LoadCredentials. _lock protects _inFlightLoad, _loadCts, and
64+
// during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and
6565
// writes to _latestCredentials.
6666
private readonly RaiiSemaphoreSlim _opLock = new(1, 1);
6767

@@ -215,26 +215,29 @@ private async Task<CredentialModel> LoadCredentialsInner(CancellationToken ct)
215215
};
216216
}
217217

218-
// Grab the lock again so we can update the state. If we got cancelled
219-
// due to a SetCredentials call, _latestCredentials will be populated so
220-
// we just return that instead.
218+
// Grab the lock again so we can update the state.
221219
using (await _opLock.LockAsync(ct))
222220
{
223-
ct.ThrowIfCancellationRequested();
224-
225-
// _latestCredentials will be set if we were preempted.
226-
var latestCreds = _latestCredentials;
227-
if (latestCreds != null) return latestCreds;
228-
229-
// Prevent new LoadCredentials calls from returning this task since
230-
// it's pretty much done.
221+
// Prevent new LoadCredentials calls from returning this task.
231222
if (_loadCts != null)
232223
{
233224
_loadCts.Dispose();
234225
_loadCts = null;
235226
_inFlightLoad = null;
236227
}
237228

229+
// If we were canceled but made it this far, try to return the
230+
// latest credentials instead.
231+
if (ct.IsCancellationRequested)
232+
{
233+
var latestCreds = _latestCredentials;
234+
if (latestCreds is not null) return latestCreds;
235+
}
236+
237+
// If there aren't any latest credentials after a cancellation, we
238+
// most likely timed out and should throw.
239+
ct.ThrowIfCancellationRequested();
240+
238241
UpdateState(model);
239242
return model;
240243
}
@@ -259,6 +262,10 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
259262
sdkClient.SetSessionToken(credentials.ApiToken);
260263
me = await sdkClient.GetUser(User.Me, ct);
261264
}
265+
catch (CoderApiHttpException)
266+
{
267+
throw;
268+
}
262269
catch (Exception e)
263270
{
264271
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
@@ -281,7 +288,10 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
281288
private void UpdateState(CredentialModel newModel)
282289
{
283290
_latestCredentials = newModel;
284-
CredentialsChanged?.Invoke(this, newModel.Clone());
291+
// Since the event handlers could block (or call back the
292+
// CredentialManager and deadlock), we run these in a new task.
293+
if (CredentialsChanged == null) return;
294+
Task.Run(() => { CredentialsChanged?.Invoke(this, newModel); });
285295
}
286296
}
287297

CoderSdk/CoderApiClient.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ public override string ConvertName(string name)
3636
}
3737

3838
[JsonSerializable(typeof(BuildInfo))]
39+
[JsonSerializable(typeof(Response))]
3940
[JsonSerializable(typeof(User))]
41+
[JsonSerializable(typeof(ValidationError))]
4042
public partial class CoderSdkJsonContext : JsonSerializerContext;
4143

4244
/// <summary>
@@ -97,17 +99,21 @@ private async Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod m
9799
}
98100

99101
var res = await _httpClient.SendAsync(request, ct);
100-
// TODO: this should be improved to try and parse a codersdk.Error response
101-
res.EnsureSuccessStatusCode();
102+
if (!res.IsSuccessStatusCode)
103+
throw await CoderApiHttpException.FromResponse(res, ct);
102104

103105
var content = await res.Content.ReadAsStringAsync(ct);
104106
var data = JsonSerializer.Deserialize<TResponse>(content, JsonOptions);
105107
if (data is null) throw new JsonException("Deserialized response is null");
106108
return data;
107109
}
110+
catch (CoderApiHttpException)
111+
{
112+
throw;
113+
}
108114
catch (Exception e)
109115
{
110-
throw new Exception($"API Request: {method} {path} (req body: {payload is not null})", e);
116+
throw new Exception($"Coder API Request failed: {method} {path}", e);
111117
}
112118
}
113119
}

CoderSdk/Errors.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
4+
namespace Coder.Desktop.CoderSdk;
5+
6+
public class ValidationError
7+
{
8+
public string Field { get; set; } = "";
9+
public string Detail { get; set; } = "";
10+
}
11+
12+
public class Response
13+
{
14+
public string Message { get; set; } = "";
15+
public string Detail { get; set; } = "";
16+
public List<ValidationError> Validations { get; set; } = [];
17+
}
18+
19+
public class CoderApiHttpException : Exception
20+
{
21+
private static readonly Dictionary<HttpStatusCode, string> Helpers = new()
22+
{
23+
{ HttpStatusCode.Unauthorized, "Try signing in again" },
24+
};
25+
26+
public readonly HttpMethod? Method;
27+
public readonly Uri? RequestUri;
28+
public readonly HttpStatusCode StatusCode;
29+
public readonly string? ReasonPhrase;
30+
public readonly Response Response;
31+
32+
public CoderApiHttpException(HttpMethod? method, Uri? requestUri, HttpStatusCode statusCode, string? reasonPhrase,
33+
Response response) : base(MessageFrom(method, requestUri, statusCode, reasonPhrase, response))
34+
{
35+
Method = method;
36+
RequestUri = requestUri;
37+
StatusCode = statusCode;
38+
ReasonPhrase = reasonPhrase;
39+
Response = response;
40+
}
41+
42+
public static async Task<CoderApiHttpException> FromResponse(HttpResponseMessage response, CancellationToken ct)
43+
{
44+
var content = await response.Content.ReadAsStringAsync(ct);
45+
Response? responseObject;
46+
try
47+
{
48+
responseObject = JsonSerializer.Deserialize<Response>(content, CoderApiClient.JsonOptions);
49+
}
50+
catch (JsonException)
51+
{
52+
responseObject = null;
53+
}
54+
55+
if (responseObject is null or { Message: null or "" })
56+
responseObject = new Response
57+
{
58+
Message = "Could not parse response, or response has no message",
59+
Detail = content,
60+
Validations = [],
61+
};
62+
63+
return new CoderApiHttpException(
64+
response.RequestMessage?.Method,
65+
response.RequestMessage?.RequestUri,
66+
response.StatusCode,
67+
response.ReasonPhrase,
68+
responseObject);
69+
}
70+
71+
private static string MessageFrom(HttpMethod? method, Uri? requestUri, HttpStatusCode statusCode,
72+
string? reasonPhrase, Response response)
73+
{
74+
var message = $"Coder API Request: {method} '{requestUri}' failed with status code {(int)statusCode}";
75+
if (!string.IsNullOrEmpty(reasonPhrase)) message += $" {reasonPhrase}";
76+
message += $": {response.Message}";
77+
if (Helpers.TryGetValue(statusCode, out var helperMessage)) message += $": {helperMessage}";
78+
if (!string.IsNullOrEmpty(response.Detail)) message += $"\n\tError: {response.Detail}";
79+
foreach (var validation in response.Validations) message += $"\n\t{validation.Field}: {validation.Detail}";
80+
return message;
81+
}
82+
}

0 commit comments

Comments
 (0)