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 .changeset/noop-api-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"PostHog": patch
---

Return no-op results instead of throwing from public APIs when PostHog API calls fail.
99 changes: 82 additions & 17 deletions src/PostHog/PostHogClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public sealed class PostHogClient : IPostHogClient
readonly IFeatureFlagCache _featureFlagsCache;
readonly MemoryCache _featureFlagCalledEventCache;
static readonly ApiResult NoOpApiResult = new(0);
static readonly Task<ApiResult> NoOpApiResultTask = Task.FromResult(NoOpApiResult);
static readonly IReadOnlyDictionary<string, FeatureFlag> EmptyFeatureFlags = new Dictionary<string, FeatureFlag>(0);

readonly TimeProvider _timeProvider;
Expand Down Expand Up @@ -173,7 +172,15 @@ public async Task<ApiResult> AliasAsync(
return NoOpApiResult;
}

return await _apiClient.AliasAsync(previousId, newId, cancellationToken);
try
{
return await _apiClient.AliasAsync(previousId, newId, cancellationToken);
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorApiCallFailed(e, nameof(AliasAsync));
return NoOpApiResult;
}
}

/// <inheritdoc/>
Expand All @@ -188,30 +195,46 @@ public async Task<ApiResult> IdentifyAsync(
return NoOpApiResult;
}

return await _apiClient.IdentifyAsync(
distinctId,
personPropertiesToSet,
personPropertiesToSetOnce,
cancellationToken);
try
{
return await _apiClient.IdentifyAsync(
distinctId,
personPropertiesToSet,
personPropertiesToSetOnce,
cancellationToken);
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorApiCallFailed(e, nameof(IdentifyAsync));
return NoOpApiResult;
}
}

/// <inheritdoc/>
public Task<ApiResult> GroupIdentifyAsync(
public async Task<ApiResult> GroupIdentifyAsync(
string type,
StringOrValue<int> key,
Dictionary<string, object>? properties,
CancellationToken cancellationToken)
{
if (CheckDisabledAndLog(nameof(GroupIdentifyAsync)))
{
return NoOpApiResultTask;
return NoOpApiResult;
}

return _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken);
try
{
return await _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken);
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorApiCallFailed(e, nameof(GroupIdentifyAsync));
return NoOpApiResult;
}
}

/// <inheritdoc/>
public Task<ApiResult> GroupIdentifyAsync(
public async Task<ApiResult> GroupIdentifyAsync(
string distinctId,
string type,
StringOrValue<int> key,
Expand All @@ -220,10 +243,18 @@ public Task<ApiResult> GroupIdentifyAsync(
{
if (CheckDisabledAndLog(nameof(GroupIdentifyAsync)))
{
return NoOpApiResultTask;
return NoOpApiResult;
}

return _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId);
try
{
return await _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId);
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorApiCallFailed(e, nameof(GroupIdentifyAsync));
return NoOpApiResult;
}
}

/// <inheritdoc/>
Expand Down Expand Up @@ -510,7 +541,7 @@ public async Task<bool> IsFeatureEnabledAsync(
return null;
}

LocalEvaluator? localEvaluator;
LocalEvaluator? localEvaluator = null;
try
{
localEvaluator = await _featureFlagsLoader.GetFeatureFlagsForLocalEvaluationAsync(cancellationToken);
Expand All @@ -520,6 +551,10 @@ public async Task<bool> IsFeatureEnabledAsync(
_logger.LogWarningQuotaExceeded(e);
return null;
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorFailedToLoadFeatureFlags(e);
}

FeatureFlag? response = null;
if (localEvaluator is not null && localEvaluator.TryGetLocalFeatureFlag(featureKey, out var localFeatureFlag))
Expand Down Expand Up @@ -928,6 +963,12 @@ public async Task<FeatureFlagEvaluations> EvaluateFlagsAsync(
errors.Add(FeatureFlagError.QuotaLimited);
fallbackToRemote = false;
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorFailedToLoadFeatureFlags(e);
errors.Add(FeatureFlagError.UnknownError);
fallbackToRemote = options is not { OnlyEvaluateLocally: true };
}
}

// 2. Remote pass — only if we still need it.
Expand Down Expand Up @@ -1040,6 +1081,14 @@ public async Task<IReadOnlyDictionary<string, FeatureFlag>> GetAllFeatureFlagsAs
_logger.LogWarningQuotaExceeded(e);
return EmptyFeatureFlags;
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorFailedToLoadFeatureFlags(e);
if (options is { OnlyEvaluateLocally: true })
{
return EmptyFeatureFlags;
}
}
}

try
Expand Down Expand Up @@ -1121,12 +1170,10 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken)
catch (ApiException e) when (e.ErrorType is "quota_limited")
{
_logger.LogWarningQuotaExceeded(e);
throw;
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorFailedToLoadFeatureFlags(e);
throw;
}
}

Expand All @@ -1138,7 +1185,14 @@ public async Task FlushAsync()
return;
}

await _asyncBatchHandler.FlushAsync();
try
{
await _asyncBatchHandler.FlushAsync();
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorApiCallFailed(e, nameof(FlushAsync));
}
}

/// <inheritdoc/>
Expand All @@ -1163,6 +1217,11 @@ public async Task FlushAsync()
_logger.LogWarningQuotaExceeded(e);
return null;
}
catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException)
{
_logger.LogErrorFailedToLoadFeatureFlags(e);
return null;
}
}

/// <inheritdoc/>
Expand Down Expand Up @@ -1361,4 +1420,10 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload(
Level = LogLevel.Warning,
Message = "[FEATURE FLAGS] distinctId is required to evaluate feature flags. Pass a distinctId explicitly or use PostHog request context for the current request.")]
public static partial void LogWarningMissingFeatureFlagDistinctId(this ILogger<PostHogClient> logger);

[LoggerMessage(
EventId = 24,
Level = LogLevel.Error,
Message = "PostHog API call failed in {MethodName}; returning a no-op result.")]
public static partial void LogErrorApiCallFailed(this ILogger<PostHogClient> logger, Exception exception, string methodName);
}
5 changes: 2 additions & 3 deletions tests/UnitTests/Features/ETagSupportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,8 @@ public async Task QuotaLimitedErrorClearsETag()

await client.LoadFeatureFlagsAsync(CancellationToken.None); // Stores ETag

// This should throw quota_limited error and clear ETag
await Assert.ThrowsAsync<ApiException>(
() => client.LoadFeatureFlagsAsync(CancellationToken.None));
// Quota-limited errors should be swallowed by the public SDK method, while still clearing the ETag.
await client.LoadFeatureFlagsAsync(CancellationToken.None);

// Next request should not have If-None-Match header (ETag was cleared)
await client.LoadFeatureFlagsAsync(CancellationToken.None);
Expand Down
Loading
Loading