Skip to content

Commit ee036de

Browse files
authored
Fix Plex user import crash on unauthorized token (#5418) (#5419)
* Fix Plex user import crash on unauthorized token (#5418) When the Plex auth token is invalid or expired, plex.tv returns an Unauthorized response whose body is an <errors> document. Two problems turned this into hard crashes that obscured the real cause: - Api.ExecuteRequest deserialized the error body with the success type's serializer, throwing a misleading "There is an error in XML document (2, 2)" exception. The deserialization is now wrapped so a non-matching error payload logs a warning and returns the default value instead. - PlexUserImporter.ImportAdmin dereferenced the account result without a null check, throwing a NullReferenceException when the account could not be retrieved. It now logs a warning and skips the admin import. Added a test covering the unauthorized (null account) path. * Evict failed responses from the API cache Previously a non-success response returned a value (including the default when error-body deserialisation failed) from inside the cache factory, so the failure was stored for the whole CacheDuration on any cacheable GET. That contradicted the "don't cache failed responses" comment and meant a transient 401 could be served from cache long after it resolved. ExecuteRequest now reports whether the response was successful, and the caching path removes the entry when it was not, so only successful responses remain cached.
1 parent 1cecdc2 commit ee036de

3 files changed

Lines changed: 61 additions & 19 deletions

File tree

src/Ombi.Api/Api.cs

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,32 +48,45 @@ public Api(ILogger<Api> log, HttpClient client, ICacheService cacheService, IHos
4848

4949
try
5050
{
51-
return await _cacheService.GetOrAddAsync(cacheKey, async () =>
51+
var wasSuccessful = true;
52+
var cachedResult = await _cacheService.GetOrAddAsync(cacheKey, async () =>
5253
{
5354
Logger.LogDebug($"ApiCache: MISS for {request.HttpMethod.Method} {request.FullUri}");
54-
return await ExecuteRequest<T>(request, cancellationToken);
55+
var (value, requestSucceeded) = await ExecuteRequest<T>(request, cancellationToken);
56+
wasSuccessful = requestSucceeded;
57+
return value;
5558
}, DateTimeOffset.UtcNow.Add(request.CacheDuration.Value));
59+
60+
// Don't keep unsuccessful responses in the cache, otherwise a transient
61+
// failure (e.g. an expired token returning a 401) would be served from the
62+
// cache for the entire cache duration.
63+
if (!wasSuccessful)
64+
{
65+
_cacheService.Remove(cacheKey);
66+
}
67+
68+
return cachedResult;
5669
}
5770
catch (JsonException ex)
5871
{
5972
// Deserialization failed - evict cache and retry
6073
Logger.LogWarning(ex, $"ApiCache: Deserialization failed for {request.FullUri}, evicting and retrying");
6174
_cacheService.Remove(cacheKey);
62-
return await ExecuteRequest<T>(request, cancellationToken);
75+
return (await ExecuteRequest<T>(request, cancellationToken)).value;
6376
}
6477
catch (Exception ex)
6578
{
6679
// Cache service failed - log and proceed without cache
6780
Logger.LogWarning(ex, $"ApiCache: Cache read failed for {request.FullUri}, proceeding without cache");
68-
return await ExecuteRequest<T>(request, cancellationToken);
81+
return (await ExecuteRequest<T>(request, cancellationToken)).value;
6982
}
7083
}
7184

7285
// No caching - execute request directly
73-
return await ExecuteRequest<T>(request, cancellationToken);
86+
return (await ExecuteRequest<T>(request, cancellationToken)).value;
7487
}
7588

76-
private async Task<T> ExecuteRequest<T>(Request request, CancellationToken cancellationToken)
89+
private async Task<(T value, bool wasSuccessful)> ExecuteRequest<T>(Request request, CancellationToken cancellationToken)
7790
{
7891
using (var httpRequestMessage = new HttpRequestMessage(request.HttpMethod, request.FullUri))
7992
{
@@ -117,17 +130,27 @@ private async Task<T> ExecuteRequest<T>(Request request, CancellationToken cance
117130
// Only cache successful responses
118131
if (!httpResponseMessage.IsSuccessStatusCode)
119132
{
120-
// For failed responses, don't cache - just deserialize and return
133+
// For failed responses, don't cache. The body is usually an error payload
134+
// (e.g. Plex returns an <errors> document on a 401) that does not match the
135+
// expected success type, so attempt to deserialize it but fall back to the
136+
// default value rather than throwing a misleading deserialization exception.
121137
var errorString = await httpResponseMessage.Content.ReadAsStringAsync(cancellationToken);
122138
LogDebugContent(errorString);
123-
if (request.ContentType == ContentType.Json)
139+
try
124140
{
125-
request.OnBeforeDeserialization?.Invoke(errorString);
126-
return JsonConvert.DeserializeObject<T>(errorString, Settings);
141+
if (request.ContentType == ContentType.Json)
142+
{
143+
request.OnBeforeDeserialization?.Invoke(errorString);
144+
return (JsonConvert.DeserializeObject<T>(errorString, Settings), false);
145+
}
146+
147+
return (DeserializeXml<T>(errorString), false);
127148
}
128-
else
149+
catch (Exception ex)
129150
{
130-
return DeserializeXml<T>(errorString);
151+
Logger.LogWarning(ex,
152+
$"Could not deserialize the error response from {request.FullUri} (Status Code: {httpResponseMessage.StatusCode}) into {typeof(T).Name}, returning default");
153+
return (default, false);
131154
}
132155
}
133156

@@ -137,13 +160,11 @@ private async Task<T> ExecuteRequest<T>(Request request, CancellationToken cance
137160
if (request.ContentType == ContentType.Json)
138161
{
139162
request.OnBeforeDeserialization?.Invoke(receivedString);
140-
return JsonConvert.DeserializeObject<T>(receivedString, Settings);
141-
}
142-
else
143-
{
144-
// XML
145-
return DeserializeXml<T>(receivedString);
163+
return (JsonConvert.DeserializeObject<T>(receivedString, Settings), true);
146164
}
165+
166+
// XML
167+
return (DeserializeXml<T>(receivedString), true);
147168
}
148169
}
149170

src/Ombi.Schedule.Tests/PlexUserImporterTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ public async Task Import_Only_Imports_Plex_Admin()
128128
_mocker.Verify<OmbiUserManager>(x => x.CreateAsync(It.IsAny<OmbiUser>()), Times.Once);
129129
}
130130

131+
[Test]
132+
public async Task Import_Admin_Handles_Null_Account_When_Unauthorized()
133+
{
134+
// Simulates Plex returning an Unauthorized response, in which case the account cannot be retrieved.
135+
_mocker.Setup<ISettingsService<UserManagementSettings>, Task<UserManagementSettings>>(x => x.GetSettingsAsync())
136+
.ReturnsAsync(new UserManagementSettings { ImportPlexAdmin = true, ImportPlexUsers = false });
137+
_mocker.Setup<IPlexApi, Task<PlexAccount>>(x => x.GetAccount(It.IsAny<string>())).ReturnsAsync((PlexAccount)null);
138+
139+
Assert.DoesNotThrowAsync(() => _subject.Execute(null));
140+
141+
_mocker.Verify<OmbiUserManager>(x => x.CreateAsync(It.IsAny<OmbiUser>()), Times.Never);
142+
_mocker.Verify<OmbiUserManager>(x => x.UpdateAsync(It.IsAny<OmbiUser>()), Times.Never);
143+
}
144+
131145
[Test]
132146
public async Task Import_Only_Imports_Plex_Admin_Already_Exists()
133147
{

src/Ombi.Schedule/Jobs/Plex/PlexUserImporter.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,14 @@ private async Task<List<OmbiUser>> ImportPlexUsers(UserManagementSettings userMa
202202
private async Task<OmbiUser> ImportAdmin(UserManagementSettings settings, PlexServers server,
203203
List<OmbiUser> allUsers)
204204
{
205-
var plexAdmin = (await _api.GetAccount(server.PlexAuthToken)).user;
205+
var plexAdmin = (await _api.GetAccount(server.PlexAuthToken))?.user;
206+
207+
if (plexAdmin == null)
208+
{
209+
// We could not retrieve the Plex account, most likely the auth token is invalid or expired.
210+
_log.LogWarning("Could not import the Plex admin, the Plex account could not be retrieved. The Plex auth token may be invalid or expired.");
211+
return null;
212+
}
206213

207214
// Check if the admin is already in the DB
208215
var adminUserFromDb = allUsers.FirstOrDefault(x =>

0 commit comments

Comments
 (0)