diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index e23258fa78f..880cff15812 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -710,13 +710,13 @@ string EncodeUrl (Uri url) request.Method = redirectState.Method; request.RequestUri = redirectState.NewUrl; } catch (Java.Net.SocketTimeoutException ex) when (JNIEnv.ShouldWrapJavaException (ex)) { - throw new WebException (ex.Message, ex, WebExceptionStatus.Timeout, null); + throw CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.Timeout); } catch (Java.Net.UnknownServiceException ex) when (JNIEnv.ShouldWrapJavaException (ex)) { - throw new WebException (ex.Message, ex, WebExceptionStatus.ProtocolError, null); + throw CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.ProtocolError); } catch (Java.Lang.SecurityException ex) when (JNIEnv.ShouldWrapJavaException (ex)) { - throw new WebException (ex.Message, ex, WebExceptionStatus.SecureChannelFailure, null); + throw CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.SecureChannelFailure); } catch (Java.IO.IOException ex) when (JNIEnv.ShouldWrapJavaException (ex)) { - throw new WebException (ex.Message, ex, WebExceptionStatus.UnknownError, null); + throw CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.UnknownError); } } } @@ -780,6 +780,13 @@ Task ConnectAsync (HttpURLConnection httpConnection, CancellationToken ct) }, ct); } + // As documented for HttpClient.SendAsync (see https://github.com/dotnet/android/issues/5761), transport + // failures must be surfaced as HttpRequestException rather than the legacy WebException. To avoid breaking + // existing code that migrated from classic Xamarin.Android and still inspects WebException (and its + // WebExceptionStatus), we keep the original WebException as the inner exception. + static HttpRequestException CreateHttpRequestException (string message, Exception? innerException, WebExceptionStatus status) + => new HttpRequestException (message, new WebException (message, innerException, status, null)); + protected virtual async Task WriteRequestContentToOutput (HttpRequestMessage request, HttpURLConnection httpConnection, CancellationToken cancellationToken) { if (request.Content is null) @@ -835,11 +842,13 @@ protected virtual async Task WriteRequestContentToOutput (HttpRequestMessage req await ConnectAsync (httpConnection, cancellationToken).ConfigureAwait (continueOnCapturedContext: false); if (Logger.LogNet) Logger.Log (LogLevel.Info, LOG_APP, $" connected"); - } catch (Java.Net.ConnectException ex) { + } catch (HttpRequestException ex) when (ex.InnerException is Java.Net.ConnectException connectException) { if (Logger.LogNet) Logger.Log (LogLevel.Info, LOG_APP, $"Connection exception {ex}"); - // Wrap it nicely in a "standard" exception so that it's compatible with HttpClientHandler - throw new WebException (ex.Message, ex, WebExceptionStatus.ConnectFailure, null); + // `ConnectAsync` wraps all connection failures in HttpRequestException, so we unwrap the original + // `Java.Net.ConnectException` here and re-wrap it to preserve the legacy WebException (with its + // WebExceptionStatus) as the inner exception for back-compat with classic Xamarin.Android. + throw CreateHttpRequestException (connectException.Message, connectException, WebExceptionStatus.ConnectFailure); } if (cancellationToken.IsCancellationRequested) { @@ -1086,7 +1095,7 @@ bool HandleRedirect (HttpStatusCode redirectCode, HttpURLConnection httpConnecti redirectState.RedirectCounter++; if (redirectState.RedirectCounter >= MaxAutomaticRedirections) - throw new WebException ($"Maximum automatic redirections exceeded (allowed {MaxAutomaticRedirections}, redirected {redirectState.RedirectCounter} times)"); + throw CreateHttpRequestException ($"Maximum automatic redirections exceeded (allowed {MaxAutomaticRedirections}, redirected {redirectState.RedirectCounter} times)", null, WebExceptionStatus.UnknownError); Uri redirectUrl; try { @@ -1123,7 +1132,7 @@ bool HandleRedirect (HttpStatusCode redirectCode, HttpURLConnection httpConnecti if (Logger.LogNet) Logger.Log (LogLevel.Debug, LOG_APP, $"Cooked redirect location: {redirectUrl}"); } catch (Exception ex) { - throw new WebException ($"Invalid redirect URI received: {location}", ex); + throw CreateHttpRequestException ($"Invalid redirect URI received: {location}", ex, WebExceptionStatus.UnknownError); } UriBuilder? builder = null; @@ -1308,7 +1317,7 @@ protected virtual Task SetupRequest (HttpRequestMessage request, HttpURLConnecti try { httpConnection.RequestMethod = request.Method.ToString (); } catch (Java.Net.ProtocolException ex) when (JNIEnv.ShouldWrapJavaException (ex)) { - throw new WebException (ex.Message, ex, WebExceptionStatus.ProtocolError, null); + throw CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.ProtocolError); } // SSL context must be set up as soon as possible, before adding any content or diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs index 00c2e237c6e..144f1f0a989 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs @@ -423,5 +423,51 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () listener.Close (); } + + [Test] + public void ConnectionFailureThrowsHttpRequestException () + { + // https://github.com/dotnet/android/issues/5761 + // HttpClient.SendAsync is documented to throw HttpRequestException when there is a problem + // connecting to the server. It must not surface the legacy WebException as the primary exception. + int unusedPort = GetAvailablePort (); + using var client = new HttpClient (new AndroidMessageHandler ()); + + var ex = Assert.CatchAsync (async () => await client.GetAsync ($"http://localhost:{unusedPort}/")); + Assert.IsInstanceOf (ex, $"Expected HttpRequestException but got {ex?.GetType ()}: {ex?.Message}"); + var inner = ex?.InnerException as WebException; + Assert.IsNotNull (inner, $"Expected inner WebException but got {ex?.InnerException?.GetType ()}"); + Assert.AreEqual (WebExceptionStatus.ConnectFailure, inner.Status, "Inner WebException should preserve ConnectFailure status"); + } + + [Test] + public void ExceedingMaxAutomaticRedirectionsThrowsHttpRequestException () + { + // https://github.com/dotnet/android/issues/5761 + // Failures in the request path must be surfaced as HttpRequestException (per the HttpClient.SendAsync + // contract). For back-compat with code migrated from classic Xamarin.Android, the legacy WebException + // (and its WebExceptionStatus) is preserved as the inner exception. + int port = GetAvailablePort (); + using var listener = new HttpListener (); + listener.Prefixes.Add ($"http://+:{port}/"); + listener.Start (); + listener.BeginGetContext (ar => { + var ctx = listener.EndGetContext (ar); + ctx.Response.StatusCode = 302; + ctx.Response.RedirectLocation = $"http://localhost:{port}/"; + ctx.Response.Close (); + }, null); + + var handler = new AndroidMessageHandler { MaxAutomaticRedirections = 1 }; + using var client = new HttpClient (handler); + + var ex = Assert.CatchAsync (async () => await client.GetAsync ($"http://localhost:{port}/")); + listener.Close (); + + Assert.IsInstanceOf (ex, $"Expected HttpRequestException but got {ex?.GetType ()}: {ex?.Message}"); + var inner = ex?.InnerException as WebException; + Assert.IsNotNull (inner, $"Expected inner WebException but got {ex?.InnerException?.GetType ()}"); + Assert.AreEqual (WebExceptionStatus.UnknownError, inner.Status, "Inner WebException should preserve UnknownError status"); + } } }