ice state failed when deployed

119 views
Skip to first unread message

the prophet

unread,
Apr 21, 2025, 1:02:16 AM4/21/25
to discuss-webrtc

hey everyone, i have been battling with my webrtc project for a long time now, i need your help so i don't lose my job. I'm trying to set up WebRTC audio streaming with a dual-peer loopback setup using a FastAPI backend and a Coturn TURN server.

✅ TURN server is running on a VPS (Dockerized).
✅ Firewall ports 3478/udp, 3478/tcp, and 49152–65535/udp are open.
✅ Cloudflare proxy is OFF for the TURN domain (DNS-only).
✅ ICE gathering finishes successfully and TURN relay candidates are discovered.
❌ But the ICE connection never succeeds — it stays stuck in "checking" then "failed."

the ice connection never succeeds but when i run it on my local system, it works fine that's with local environment and all, but it seems to just fail when i run it from my vps, below is my python code:
 "import os

import asyncio
import json
import logging

from fastapi import FastAPI, WebSocket
from aiortc import (
RTCPeerConnection,
RTCSessionDescription,
MediaStreamTrack,
RTCIceCandidate,
RTCConfiguration,
RTCIceServer,
)
from dotenv import load_dotenv

# ─── Load environment vars from .env ─────────────────────────────────────────
load_dotenv()

DOMAIN = os.getenv("DOMAIN", "127.0.0.1")
TURN_USER = os.getenv("TURN_USER")
TURN_PASS = os.getenv("TURN_PASS")
PORT = int(os.getenv("PORT", 8001))

# ─── Logging setup ────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("loopback")

# ─── FastAPI app ──────────────────────────────────────────────────────────────
app = FastAPI()

peer_in = None
peer_out = None
websocket_out = None

# ─── ICE configuration ─────────────────────────────────────────────────────────
config = RTCConfiguration(
iceServers=[
RTCIceServer(urls=["stun:stun.l.google.com:19302"]),
RTCIceServer(
urls=[f"turn:{DOMAIN}:3478?transport=udp"],
username=TURN_USER,
credential=TURN_PASS,
),
]
)


class AudioForwarder(MediaStreamTrack):
kind = "audio"

def __init__(self, source):
super().__init__() # don't forget this
self.track = source

async def recv(self):
frame = await self.track.recv()
return frame


@app.get("/")
def hello():
return "Hello World"


def parse_candidate(candidate_dict):
try:
parts = candidate_dict["candidate"].split()
return RTCIceCandidate(
foundation=parts[0].split(":")[1],
component=int(parts[1]),
protocol=parts[2].lower(),
priority=int(parts[3]),
ip=parts[4],
port=int(parts[5]),
type=parts[7],
sdpMid=candidate_dict.get("sdpMid"),
sdpMLineIndex=candidate_dict.get("sdpMLineIndex", 0),
)
except Exception as e:
logger.error("[ERROR] Failed to parse ICE candidate: %s", e)
return None


@app.websocket("/loopback/send")
async def loopback_sender(websocket: WebSocket):
global peer_in, peer_out, websocket_out
await websocket.accept()
pc = RTCPeerConnection(configuration=config)
peer_in = pc
logger.info("/loopback/send WebSocket accepted")

@pc.on("track")
async def on_track(track):
logger.info("[Sender] Received %s track", track.kind)
if track.kind == "audio":
forwarder = AudioForwarder(track)
# wait for peer_out to be ready
for _ in range(30):
if peer_out and peer_out.connectionState != "closed":
break
await asyncio.sleep(0.1)
if not peer_out or peer_out.connectionState == "closed":
return logger.error("[ERROR] peer_out not ready")
peer_out.addTrack(forwarder)
try:
offer = await peer_out.createOffer()
await peer_out.setLocalDescription(offer)
if websocket_out:
await websocket_out.send_json(
{
"type": "offer",
"data": {"type": offer.type, "sdp": offer.sdp},
}
)
logger.info("[Server] Sent loopback offer to /recv")
else:
logger.warning("[WARN] websocket_out is None")
except Exception as e:
logger.error("[ERROR] Failed to send offer to receiver: %s", e)

try:
while True:
msg = await websocket.receive_text()
data = json.loads(msg)

if data.get("type") == "offer":
await pc.setRemoteDescription(RTCSessionDescription(**data["data"]))
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await websocket.send_json(
{"type": "answer", "data": {"type": answer.type, "sdp": answer.sdp}}
)
logger.info("[Server] Sent answer to sender")

elif data.get("type") == "candidate":
cand = parse_candidate(data["data"])
if cand:
await pc.addIceCandidate(cand)
logger.info("[Server] Added ICE candidate (send)")
except Exception as e:
logger.error("[ERROR] Sender loop: %s", e)
finally:
await pc.close()
logger.info("[Server] Sender connection closed")


@app.websocket("/loopback/recv")
async def loopback_receiver(websocket: WebSocket):
global peer_out, websocket_out
await websocket.accept()
websocket_out = websocket
pc = RTCPeerConnection(configuration=config)
peer_out = pc
logger.info("/loopback/recv WebSocket accepted")

try:
while True:
msg = await websocket.receive_text()
data = json.loads(msg)

if data.get("type") == "answer":
await pc.setRemoteDescription(RTCSessionDescription(**data["data"]))
logger.info("[Server] Receiver set remote answer")

elif data.get("type") == "candidate":
cand = parse_candidate(data["data"])
if cand:
await pc.addIceCandidate(cand)
logger.info("[Server] Added ICE candidate (recv)")
except Exception as e:
logger.error("[ERROR] Receiver loop: %s", e)
finally:
await pc.close()
logger.info("[Server] Receiver connection closed")


if __name__ == "__main__":
import uvicorn

uvicorn.run("main:app", host="0.0.0.0", port=PORT, log_level="info")

"

and this is my frontend code "<!DOCTYPE html>

<html lang="en">

<head>
<meta charset="UTF-8" />
<title>WebRTC Dual Peer Loopback</title>
</head>

<body>
<h1>Loopback Audio Test</h1>
<button id="startBtn">Start Loopback</button>
<audio id="remoteAudio" autoplay controls></audio>

<script>
// ─── CONFIGURATION ───────────────────────────────────────────
const SIGNALING_SERVER = 'wss://api.reedapt.com';
const TURN_HOST = 'turn.reedapt.com';
const TURN_PORT = 3478;
const TURN_USER = 'sample_user'; //this is not the correct user or password
const TURN_PASS = 'sample_password';
// ─────────────────────────────────────────────────────────────

const iceServers = [
{ urls: ['stun:stun.l.google.com:19302'] },
{
// Note: URLs must be an array, and we add ?transport=udp
urls: [`turn:${TURN_HOST}:${TURN_PORT}?transport=udp`],
username: TURN_USER,
credential: TURN_PASS
}
];

document.getElementById('startBtn').onclick = async () => {
const remoteAudio = document.getElementById('remoteAudio');

// Create two RTCPeerConnections with the same ICE config
const pcSend = new RTCPeerConnection({ iceServers });
const pcRecv = new RTCPeerConnection({ iceServers });

const wsSend = new WebSocket(`${SIGNALING_SERVER}/loopback/send`);
const wsRecv = new WebSocket(`${SIGNALING_SERVER}/loopback/recv`);

// ── ICE state logging ──────────────────────────────────────
pcSend.oniceconnectionstatechange = () =>
console.log("📡 pcSend ICE state:", pcSend.iceConnectionState);
pcRecv.oniceconnectionstatechange = () =>
console.log("📡 pcRecv ICE state:", pcRecv.iceConnectionState);
// ───────────────────────────────────────────────────────────

// ── pcRecv (PLAYBACK) ─────────────────────────────────────
pcRecv.ontrack = ({ streams: [stream] }) => {
if (remoteAudio.srcObject !== stream) {
remoteAudio.srcObject = stream;
console.log("✅ Receiving loopback audio");
}
};

wsRecv.onmessage = async (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === 'offer') {
await pcRecv.setRemoteDescription(new RTCSessionDescription(msg.data));
const answer = await pcRecv.createAnswer();
await pcRecv.setLocalDescription(answer);
wsRecv.send(JSON.stringify({ type: 'answer', data: answer }));
console.log("📥 pcRecv answered offer");
}
};
pcRecv.onicecandidate = (evt) => {
if (evt.candidate) {
wsRecv.send(JSON.stringify({ type: 'candidate', data: evt.candidate }));
}
};
// ───────────────────────────────────────────────────────────

// ── pcSend (MIC → SERVER) ─────────────────────────────────
wsSend.onopen = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => pcSend.addTrack(track, stream));
console.log("🎤 Added local audio track(s)");

const offer = await pcSend.createOffer();
await pcSend.setLocalDescription(offer);
wsSend.send(JSON.stringify({ type: 'offer', data: offer }));
console.log("📡 pcSend sent offer");
};
wsSend.onmessage = async (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === 'answer') {
await pcSend.setRemoteDescription(new RTCSessionDescription(msg.data));
console.log("✅ pcSend set remote answer");
}
};
pcSend.onicecandidate = (evt) => {
if (evt.candidate) {
wsSend.send(JSON.stringify({ type: 'candidate', data: evt.candidate }));
}
};
// ───────────────────────────────────────────────────────────
};
</script>
</body>

</html>"
please help/guide me

eggert.c...@googlemail.com

unread,
Apr 21, 2025, 1:57:28 AM4/21/25
to discuss-webrtc
For these types of questions, the resulting SDPs of your failed attempt (maybe also in contrast to the working attempt) are much more interesting than just seeing the code. And especially which ice candidate was used in your local attempt.
If I had to guess from glancing over, I would assume that your local attempt works via TURN, but since you add that server with 127.0.0.1 on your python end, it only works when things are running on the same machine. The other candidates might still fail for other machine setup reasons.
On a side note, I would never share code with information of your company, and using Google's STUN servers for a productive environment is probably not a good idea.

Reply all
Reply to author
Forward
0 new messages