OrchardCMS/OrchardCore · error · Error

Negotiate redirection limit exceeded.

Error message

Negotiate redirection limit exceeded.

What it means

The negotiate loop follows server-issued redirects (negotiateResponse.url) up to MAX_REDIRECTS times; if the limit is reached and the response still carries a redirect url, the client gives up with this error. It protects against infinite redirect loops between servers/load balancers during the negotiate handshake.

Solutions

  1. Inspect the sequence of /negotiate requests (DevTools/network logs) to find the redirect loop and fix the server that keeps redirecting.
  2. Ensure at least one server in the chain returns a terminal negotiate response (no url field).
  3. Fix load balancer/sticky session (ARR affinity) configuration so negotiate lands on a node that serves the connection.
  4. Remove redirect logic that points back to the originating URL.

Example fix

// server before: each node always redirects
return Ok(new { url = $"https://{otherNode}/hub" });
// server after: only one node redirects; terminal node omits url
return Ok(new {
  connectionId,
  availableTransports = /* ... */
  // no 'url' field
});
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await connection.start();
} catch (err) {
  if (err.message.includes("Negotiate redirection limit exceeded")) {
    console.error("Negotiate redirect loop detected — inspect load balancer/server redirects");
    alertOps("signalr-negotiate-redirect-loop");
  } else { throw err; }
}

Prevention

When it happens

Trigger: The negotiate endpoint (or a chain of servers) repeatedly returns { url: ... } responses exceeding MAX_REDIRECTS — e.g. two load-balanced nodes redirecting to each other, or a server emitting a redirect that points back to itself.

Common situations: Misconfigured load balancers or reverse proxies ping-ponging negotiate requests; multi-server backplanes with per-node redirect logic without a terminating node; sticky-session misconfiguration causing each node to redirect to another.

Related errors


AI-assisted analysis of OrchardCMS/OrchardCore@4306c0717f (2026-09-13). Data as JSON: /api/errors/73294657a8af5834. Report an issue: GitHub.

Appendix: source

Thrown at src/OrchardCore.Modules/OrchardCore.SignalR/wwwroot/Scripts/signalr.js:2867

                    if (negotiateResponse.ProtocolVersion) {
                        throw new Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");
                    }
                    if (negotiateResponse.url) {
                        url = negotiateResponse.url;
                    }
                    if (negotiateResponse.accessToken) {
                        // Replace the current access token factory with one that uses
                        // the returned access token
                        const accessToken = negotiateResponse.accessToken;
                        this._accessTokenFactory = () => accessToken;
                        // set the factory to undefined so the AccessTokenHttpClient won't retry with the same token, since we know it won't change until a connection restart
                        this._httpClient._accessToken = accessToken;
                        this._httpClient._accessTokenFactory = undefined;
                    }
                    redirects++;
                } while (negotiateResponse.url && redirects < MAX_REDIRECTS);
                if (redirects === MAX_REDIRECTS && negotiateResponse.url) {
                    throw new Error("Negotiate redirection limit exceeded.");
                }
                await this._createTransport(url, this._options.transport, negotiateResponse, transferFormat);
            }
            if (this.transport instanceof LongPollingTransport) {
                this.features.inherentKeepAlive = true;
            }
            if (this._connectionState === "Connecting" /* ConnectionState.Connecting */) {
                // Ensure the connection transitions to the connected state prior to completing this.startInternalPromise.
                // start() will handle the case when stop was called and startInternal exits still in the disconnecting state.
                this._logger.log(LogLevel.Debug, "The HttpConnection connected successfully.");
                this._connectionState = "Connected" /* ConnectionState.Connected */;
            }
            // stop() is waiting on us via this.startInternalPromise so keep this.transport around so it can clean up.
            // This is the only case startInternal can exit in neither the connected nor disconnected state because stopConnection()
            // will transition to the disconnected state. start() will wait for the transition using the stopPromise.
        }
        catch (e) {
            this._logger.log(LogLevel.Error, "Failed to start the connection: " + e);

View on GitHub (pinned to 4306c0717f)