Description
When first using this library, we found it difficult to debug issues when a registration wasn't found that we expected. We use Refit and found that sometimes the mismatch was due to order of url params or encoding issues. We also have test infra to configure common calls like auth sequences for various integrations.
We ended up extending IntereceptingHttpMessageHandler to enrich the HttpRequestNotInterceptedException with a list of registered interceptions. Below is the handler we created. It's not perfect since it can't easily report custom matching, but it significantly improved our experience of troubleshooting missing registrations. I've been intending to offer this as a PR but I don't think I'll have time soon so wanted to offer this for anyone it might benefit or would like to use for a PR.
internal class FriendlierErrorInterceptingHttpMessageHandler(HttpClientInterceptorOptions options)
: InterceptingHttpMessageHandler(options)
{
internal readonly HttpClientInterceptorOptions Options = options;
internal Task<HttpResponseMessage> SendAsyncInternal(HttpRequestMessage request,
CancellationToken cancellationToken) =>
SendAsync(request, cancellationToken);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
return await base.SendAsync(request, cancellationToken);
}
catch (HttpRequestNotInterceptedException e)
{
var registeredMappings = GetRegisteredMappings().ToOrderedDelimitedString(Environment.NewLine);
var unescapedDataString = Uri.UnescapeDataString(request.RequestUri!.AbsoluteUri);
var recordableOptions = Options as RecordableHttpClientInterceptorOptions;
var msg = $"No HTTP response is configured for {recordableOptions?.TestFilePath}" +
$"\n\n{request.Method.Method} {request.RequestUri!.AbsoluteUri}" +
$"\n({unescapedDataString})";
throw e.Request is null
? new HttpRequestNotInterceptedException($"{msg}\n\nRegistered Mappings:\n\n{registeredMappings}")
: new HttpRequestNotInterceptedException($"{msg}\n\nRegistered Mappings:\n\n{registeredMappings}", e.Request);
}
}
private IEnumerable<string> GetRegisteredMappings()
{
var mappings = (IDictionary)typeof(HttpClientInterceptorOptions)
.GetField("_mappings", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(Options)!;
if(mappings.Count == 0)
return Array.Empty<string>();
// Keys prefixed with CUSTOM: will not show the query params of the URI.
// We will append them at the end of the method.
var registrations = mappings.Values.Cast<object>().ToCollection();
// sealed internal class: HttpInterceptionResponse
var type = registrations.First().GetType();
Dictionary<string, PropertyInfo> propertyInfos = type
.GetProperties(BindingFlags.Instance|BindingFlags.NonPublic)
.ToDictionary(d => d.Name);
string? GetValue(string propertyName, object o) => propertyInfos[propertyName].GetValue(o)?.ToString();
string? IfExists(string propertyName, object o, string text) =>
propertyInfos[propertyName].GetValue(o) != null ? $" {text}" : null;
string? IfTrue(string propertyName, object o, string text) =>
((bool?)propertyInfos[propertyName].GetValue(o)).GetValueOrDefault() ? $" {text}" : null;
return registrations
.Select(o => $"{GetValue("Method", o)} {GetValue("RequestUri", o)}" +
$"{IfExists("ContentMatcher", o, "+content-matching")}" +
$"{IfExists("UserMatcher", o, "+user-matching")}" +
$"{IfTrue("IgnoreHost", o, "ignore-host")}" +
$"{IfTrue("IgnorePath", o, "ignore-path")}" +
$"{IfTrue("IgnoreQuery", o, "ignore-query")}")
.Concat(HttpRequestInterceptionBuilderExtensions.GetCurrentTestUnescapedQueries());
}
}