Blazor WASM Client App: Unable to read Location Response Header from HTTP Client

Viewed 218

The Issue

I'm running into an issue where I'm unable to access the 'Location' response header (or really, any response header other than 'Content-Length') from the response of an HTTP POST, using an HTTP Client inside of a Blazor Web App.

I've confirmed that the API we are calling is indeed returning the Location header; Captured via the developer tools within Chrome: enter image description here

The Code in my Blazor Client Application

We don't do anything out of the ordinary when we create the Typed/HTTP Client and register it with DI; we're just setting up the timeout on the client and Polly Retries. The handlers we're registering are for Authentication and logging respectively.

public static IServiceCollection AddInternalApiClient(this IServiceCollection services)
{
    _ = services ?? throw new ArgumentNullException(nameof(services));
    services.AddOptions<InternalApiClientOptions>().Configure<IConfiguration>((options, configuration) => configuration.GetSection(nameof(InternalApiClientOptions)).Bind(options));
    services
        .AddHttpClient<InternalApiClient>((serviceProvider, client) =>
        {
            var config = serviceProvider.GetRequiredService<IOptions<InternalApiClientOptions>>().Value;
            client.BaseAddress = new Uri(config.BaseAddress, UriKind.Absolute);
            client.Timeout = config.Timeout;
        })
        .AddHttpMessageHandler<ApiAuthorizationMessageHandler>()
        .AddHttpMessageHandler<ApiMetricsHandler>()
        .AddPolicyHandler((serviceProvider, _) =>
        {
            var config = serviceProvider.GetRequiredService<IOptions<InternalApiClientOptions>>().Value;
            return HttpPolicyExtensions
                .HandleTransientHttpError()
                .WaitAndRetryAsync(config.Retries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(config.Backoff, retryAttempt)));
        });

    return services;
}
public class ApiAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public ApiAuthorizationMessageHandler(
        IAccessTokenProvider provider,
        NavigationManager navigationManager,
        string[] authorizedUrlArray,
        string scope)
       : base(provider, navigationManager)
    {
        ConfigureHandler(
            authorizedUrls: authorizedUrlArray,
            scopes: new[] { scope });
    }
}
public class ApiMetricsHandler : DelegatingHandler
{
    private readonly SessionManager _sessionManager;
    private readonly AppVersionInfo _appVersionInfo;
    private readonly IMetricsMonitor _metricsMonitor;

    public ApiMetricsHandler(SessionManager sessionManager, AppVersionInfo appVersionInfo, IMetricsMonitor metricsMonitor)
    {
        _sessionManager = sessionManager;
        _appVersionInfo = appVersionInfo;
        _metricsMonitor = metricsMonitor;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Dictionary<string, object> transactionDetails = new Dictionary<string, object>()
            {
                { "SessionId", _sessionManager.SessionId },
                // Etc.
            };

        _metricsMonitor.TrackEvent(_sessionManager.CurrentTransactionId, transactionDetails);

        return await base.SendAsync(request, cancellationToken);
    }
}

As for the Typed Client itself, its a standard NSwag-generated client. Below is a HEAVILY edited version for brevity.

public partial class InternalApiClient
{
    private HttpClient _httpClient;
    private Lazy<JsonSerializerSettings> _settings;

    public InternalApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient; 
        _settings = new Lazy<JsonSerializerSettings>(() => 
        {
            var settings = new JsonSerializerSettings();
            UpdateJsonSerializerSettings(settings);
            return settings;
        });
    }
    
    protected JsonSerializerSettings JsonSerializerSettings { get { return _settings.Value; } }

    // FWIW, these aren't defined elsewhere.
    partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings);
    partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url);
    partial void PrepareRequest(HttpClient client, HttpRequestMessage request, StringBuilder urlBuilder);
    partial void ProcessResponse(HttpClient client, HttpResponseMessage response);

    public async Task<SwaggerResponse> PostAsync(Request body, CancellationToken cancellationToken)
    {
        var urlBuilder_ = new StringBuilder();
        urlBuilder_.Append("endpoint/path");
    
        var client_ = _httpClient;
        try
        {
            using (var request_ = new HttpRequestMessage())
            {
                var content_ = new StringContent(JsonConvert.SerializeObject(body, _settings.Value));
                content_.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
                request_.Content = content_;
                request_.Method = new HttpMethod("POST");
    
                PrepareRequest(client_, request_, urlBuilder_);
                var url_ = urlBuilder_.ToString();
                request_.RequestUri = new System.Uri(url_, UriKind.RelativeOrAbsolute);
                PrepareRequest(client_, request_, url_);
    
                var response_ = await client_.SendAsync(request_, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
                try
                {
                    var headers_ = Enumerable.ToDictionary(response_.Headers, h_ => h_.Key, h_ => h_.Value);
                    if (response_.Content != null && response_.Content.Headers != null)
                    {
                        foreach (var item_ in response_.Content.Headers)
                            headers_[item_.Key] = item_.Value;
                    }
    
                    ProcessResponse(client_, response_);
    
                    var status_ = ((int)response_.StatusCode).ToString();
                    if (status_ == "400") 
                    {
                        var objectResponse_ = await ReadObjectResponseAsync<ProblemDetails>(response_, headers_).ConfigureAwait(false);
                        throw new ApiException<ProblemDetails>("bad request", (int)response_.StatusCode, objectResponse_.Text, headers_, objectResponse_.Object, null);
                    }
                    else
                    if (status_ == "202") 
                    {
                        return new SwaggerResponse((int)response_.StatusCode, headers_);
                    }
                    // Etc
                    else
                    if (status_ != "200" && status_ != "204")
                    {
                        var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); 
                        throw new ApiException("The HTTP status code of the response was not expected (" + (int)response_.StatusCode + ").", (int)response_.StatusCode, responseData_, headers_, null);
                    }
    
                    return new SwaggerResponse((int)response_.StatusCode, headers_); 
                }
                finally
                {
                    if (response_ != null)
                        response_.Dispose();
                }
            }
        }
        finally
        {
        }
    }
}

Does this work elsewhere?

I ran a test using a different project that uses the same generated API Client Class, in particular an Azure Functions project. When running in that project, I was able to receive all 8 response headers in the image above.

The DI setup logic is slightly different, but the HTTP Client setup poritions remain the same:

public static IServiceCollection AddInternalApiClient(this IServiceCollection services)
{
    _ = services ?? throw new ArgumentNullException(nameof(services));

    services.AddOptions<InternalApiClientOptions>().Configure<IConfiguration>((options, configuration) => configuration.GetSection(nameof(InternalApiClientOptions)).Bind(options));

    services
        .AddHttpClient<InternalApiClient>((serviceProvider, client) =>
        {
            var config = serviceProvider.GetRequiredService<IOptions<InternalApiClientOptions>>().Value;
            client.BaseAddress = new Uri(config.BaseAddress, UriKind.Absolute);
            client.Timeout = config.Timeout;
        })
        .AddAzureIdentityCredentials(scopeFactory: (sp, _) =>
        {
            var config = sp.GetRequiredService<IOptions<InternalApiClientOptions>>().Value;
            return $"{config.Scope}/.default";
        })
        .AddPolicyHandler((serviceProvider, _) =>
        {
            var config = serviceProvider.GetRequiredService<IOptions<InternalApiClientOptions>>().Value;

            return HttpPolicyExtensions
                    .HandleTransientHttpError()
                    .WaitAndRetryAsync(config.Retries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(config.Backoff, retryAttempt)));
        });

    return services;
}

Final Thoughts

Because I'm able to access the response headers when using the same API Client in a different project, it feels like the issue is either Blazor as a whole, or my usage of the API client through it (via the Message Handlers). I've seen other questions/bugs regarding the Blazor and headers:

The responses for these all seem to be: Update the CORS policies on the Server. I'm not sure if this IS a CORS issue though, as a different project can receive all of the headers just fine (and the 'Content-Length' header is always received no matter what). The Server itself is used by other Apps than the two I mentioned, so I'm hesitant to start adding the 'AddCors/UseCors' calls that the linked issues mentioned.

Any insight, references would be greatly appreciated.

0 Answers
Related