dotnet/aspnetcore · error · RuntimeException

Negotiate redirection limit exceeded.

Error message

Negotiate redirection limit exceeded.

What it means

Thrown by startNegotiate when the server returns a redirect URL and the negotiate attempt count has reached MAX_NEGOTIATE_ATTEMPTS (100). SignalR negotiate can redirect (e.g. for Azure SignalR Service routing); the client follows each redirect and re-negotiates, but caps the chain at 100 to avoid loops. Exceeding the cap means redirects are not converging on a final endpoint.

Solutions

  1. Inspect the redirect chain (URLs and attempt count) - a loop or oscillation points to the misconfigured endpoint.
  2. Fix the server/infrastructure so negotiate returns a terminal response (no redirect) within a small number of hops.
  3. Verify Azure SignalR Service / reverse proxy routing rules do not create cycles.
  4. If you genuinely need many redirects, that is a defect to fix server-side rather than raise the cap.

Example fix

// before: two endpoints redirect to each other
// A/negotiate -> Location: B/negotiate -> Location: A/negotiate -> ... throws after 100

// after: configure endpoints so negotiate at A returns a terminal response
// (fix server-side routing / Azure SignalR Service config; no client change needed).
Defensive patterns

Strategy: try-catch

Validate before calling

// No client-side validation prevents a server-driven redirect loop.
// In test/staging, probe the negotiate endpoint and count redirects manually:
int hops = countNegotiateRedirects(url); // curl -i loop in your env
if (hops > 5) { /* investigate server/Azure SignalR routing config */ }

Type guard

// Remote redirect chain cannot be type-guarded.
// Log each redirect URL to detect loops or oscillation.

Try / catch

try {
  conn.start().blockingAwait();
} catch (RuntimeException e) {
  if ("Negotiate redirection limit exceeded.".equals(e.getMessage())) {
    // inspect server/Azure SignalR Service routing; fix the loop server-side
  } else throw e;
}

Prevention

When it happens

Trigger: Each negotiate response carries getRedirectUrl() != null and the client follows it; after 100 such redirects without a terminal response, startNegotiate throws. Caused by a redirect loop, a misconfigured multi-endpoint setup, or an infrastructure layer that keeps bouncing the client.

Common situations: Azure SignalR Service misconfigured with a redirect loop between endpoints; a load balancer/proxy rewriting Location into another redirect; multiple SignalR services pointing at each other; a server bug always returning a redirect.

Related errors


AI-assisted analysis of dotnet/aspnetcore@3600ca084e (2026-08-11). Data as JSON: /api/errors/cece881db27f27e7. Report an issue: GitHub.

Appendix: source

Thrown at src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/HubConnection.java:374

                }

                localStart.onError(error);
            });
        } finally {
            this.state.lock.unlock();
        }

        return localStart;
    }

    private Single<NegotiateResponse> startNegotiate(String url, int negotiateAttempts, Map<String, String> localHeaders) {
        if (this.state.getHubConnectionState() != HubConnectionState.CONNECTING) {
            throw new RuntimeException("HubConnection trying to negotiate when not in the CONNECTING state.");
        }

        return handleNegotiate(url, localHeaders).flatMap(response -> {
            if (response.getRedirectUrl() != null && negotiateAttempts >= MAX_NEGOTIATE_ATTEMPTS) {
                throw new RuntimeException("Negotiate redirection limit exceeded.");
            }

            if (response.getRedirectUrl() == null) {
                Set<String> transports = response.getAvailableTransports();
                if (this.transportEnum == TransportEnum.ALL) {
                    if (transports.contains("WebSockets")) {
                        response.setChosenTransport(TransportEnum.WEBSOCKETS);
                    } else if (transports.contains("LongPolling")) {
                        response.setChosenTransport(TransportEnum.LONG_POLLING);
                    } else {
                        throw new RuntimeException("There were no compatible transports on the server.");
                    }
                } else if (this.transportEnum == TransportEnum.WEBSOCKETS && !transports.contains("WebSockets") ||
                        (this.transportEnum == TransportEnum.LONG_POLLING && !transports.contains("LongPolling"))) {
                    throw new RuntimeException("There were no compatible transports on the server.");
                } else {
                    response.setChosenTransport(this.transportEnum);
                }

View on GitHub (pinned to 3600ca084e)