From 933318428b3857c2525f3e4cd2adc5f519037151 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:52:59 +0000 Subject: [PATCH 1/4] Initial plan From e9d69b03894e0017edf1e3a9346cc8279cec1dfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:22:18 +0000 Subject: [PATCH 2/4] Throw HttpRequestException instead of WebException in AndroidMessageHandler Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com> --- .../AndroidMessageHandler.cs | 23 ++++++++++++------- .../AndroidMessageHandlerTests.cs | 13 +++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index e23258fa78f..6660cc074e9 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) @@ -839,7 +846,7 @@ protected virtual async Task WriteRequestContentToOutput (HttpRequestMessage req 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); + throw CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.ConnectFailure); } if (cancellationToken.IsCancellationRequested) { @@ -1086,7 +1093,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 +1130,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 +1315,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..819c1a30363 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,18 @@ 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}"); + } } } From 47fb41874481b7cf7f990409892a1912af338bbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:27:13 +0000 Subject: [PATCH 3/4] Add redirect test asserting inner WebException is preserved Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com> --- .../AndroidMessageHandlerTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 819c1a30363..824c377ba50 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 @@ -436,5 +436,33 @@ public void ConnectionFailureThrowsHttpRequestException () var ex = Assert.CatchAsync (async () => await client.GetAsync ($"http://localhost:{unusedPort}/")); Assert.IsInstanceOf (ex, $"Expected HttpRequestException but got {ex?.GetType ()}: {ex?.Message}"); } + + [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}"); + Assert.IsInstanceOf (ex?.InnerException, $"Expected inner WebException but got {ex?.InnerException?.GetType ()}"); + } } } From 28f733d9984bae1b19cf190a909b13e89e298447 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:36:52 +0000 Subject: [PATCH 4/4] Catch wrapped ConnectException and assert inner WebException status Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com> --- .../Xamarin.Android.Net/AndroidMessageHandler.cs | 8 +++++--- .../Xamarin.Android.Net/AndroidMessageHandlerTests.cs | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index 6660cc074e9..880cff15812 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -842,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 CreateHttpRequestException (ex.Message, ex, WebExceptionStatus.ConnectFailure); + // `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) { 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 824c377ba50..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 @@ -435,6 +435,9 @@ public void ConnectionFailureThrowsHttpRequestException () 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] @@ -462,7 +465,9 @@ public void ExceedingMaxAutomaticRedirectionsThrowsHttpRequestException () listener.Close (); Assert.IsInstanceOf (ex, $"Expected HttpRequestException but got {ex?.GetType ()}: {ex?.Message}"); - Assert.IsInstanceOf (ex?.InnerException, $"Expected inner WebException but got {ex?.InnerException?.GetType ()}"); + 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"); } } }