Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ require (
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731
github.com/livekit/media-sdk v0.0.0-20260612175532-3d4d26d136c9
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0
github.com/livekit/protocol v1.46.7-0.20260610064410-e286afe70eb0
github.com/livekit/protocol v1.47.1-0.20260618140803-db77a56cf894
github.com/livekit/psrpc v0.7.2
github.com/livekit/server-sdk-go/v2 v2.16.7-0.20260608025623-a5da15b13baa
github.com/livekit/server-sdk-go/v2 v2.16.7-0.20260618140743-3776341a116e
github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb
github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12
github.com/ory/dockertest/v3 v3.12.0
github.com/pion/rtp v1.10.2
github.com/pion/sdp/v3 v3.0.18
github.com/pion/webrtc/v4 v4.2.11
github.com/pion/webrtc/v4 v4.2.14
github.com/prometheus/client_golang v1.23.2
github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
Expand Down Expand Up @@ -102,7 +102,7 @@ require (
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/sctp v1.9.5 // indirect
github.com/pion/sctp v1.10.0 // indirect
github.com/pion/srtp/v3 v3.0.11 // indirect
github.com/pion/stun/v3 v3.1.4 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
Expand Down
18 changes: 8 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ github.com/livekit/media-sdk v0.0.0-20260612175532-3d4d26d136c9 h1:GVMkuNwXQ74kV
github.com/livekit/media-sdk v0.0.0-20260612175532-3d4d26d136c9/go.mod h1:TuYRjSepaakL6ATsM9V2VMuksewW1PlhA32BG7Pxty0=
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I=
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.46.7-0.20260610064410-e286afe70eb0 h1:aNazCl+gTEmF88tVsISOvtnfZM/K9IbqAn2WvZVmh4Y=
github.com/livekit/protocol v1.46.7-0.20260610064410-e286afe70eb0/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/protocol v1.47.1-0.20260618140803-db77a56cf894 h1:OH1Fejt3yDQXG2bYs1LkaSxifdVlm61eG3yrzrLW6Jo=
github.com/livekit/protocol v1.47.1-0.20260618140803-db77a56cf894/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc=
github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
github.com/livekit/server-sdk-go/v2 v2.16.7-0.20260608025623-a5da15b13baa h1:B19yilP7+JjekKMD0WejMh1Kvypdxpr5yxQZiFStRD0=
github.com/livekit/server-sdk-go/v2 v2.16.7-0.20260608025623-a5da15b13baa/go.mod h1:SWJD68Rfcwrhze09EYaRiur7ESCBuu0u4fpK+0BGEYo=
github.com/livekit/server-sdk-go/v2 v2.16.7-0.20260618140743-3776341a116e h1:PJZ+9COhAT8sCIo6zJCtYaDeJBQcCUN8H0GyEe2xMMM=
github.com/livekit/server-sdk-go/v2 v2.16.7-0.20260618140743-3776341a116e/go.mod h1:fuOvpz1rjH2XgsaXiVKSzW1tPw7es5dmBjr4GrX7xd8=
github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb h1:HmgaJMGs0Nco/Z+XMc9f+xFgrbood9yJsIBtl1OY76M=
github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb/go.mod h1:aDa6mbFktNzA1D917RhFlIB5IOfNBTmrwt+/lX960j0=
github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94=
Expand Down Expand Up @@ -196,8 +196,8 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
Expand All @@ -208,12 +208,10 @@ github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkY
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8=
github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU=
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
github.com/pion/webrtc/v4 v4.2.14 h1:Q6zMs+fSDsYuhZcNlvFGBxCOMHVV9oYcDa6O9/HIGTc=
github.com/pion/webrtc/v4 v4.2.14/go.mod h1:87NVKP86+g4OMrRxWhjWfUjeXP4JrV6RTlUrIW+/Jak=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down
6 changes: 5 additions & 1 deletion pkg/sip/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,11 @@ func (c *Client) onBye(req *sip.Request, tx sip.ServerTransaction) bool {
call.log.Infow("BYE from remote")
go func(call *outboundCall) {
call.cc.AcceptBye(req, tx)
call.CloseWithReason(ctx, CallHangup, stats.Success("bye"), livekit.DisconnectReason_CLIENT_INITIATED)
call.CloseWith(ctx, EndCall{
Status: CallHangup,
Term: stats.Success("bye"),
Reason: livekit.DisconnectReason_CLIENT_INITIATED,
})
}(call)
return true
}
Expand Down
99 changes: 51 additions & 48 deletions pkg/sip/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ import (
// inviteFailure is the verdict for a failed outbound INVITE: how to record
// the call, how to bucket the SLI, and which error to surface back.
type inviteFailure struct {
status CallStatus
term stats.Termination
reason livekit.DisconnectReason
reportErr error // nil skips writing SIPCallInfo.Error
EndCall
returnErr error
}

Expand Down Expand Up @@ -54,18 +51,20 @@ func (e SDPError) GRPCStatus() *status.Status {

func (e SDPError) ClassifyInvite() inviteFailure {
res := inviteFailure{
status: callRejected,
reason: livekit.DisconnectReason_MEDIA_FAILURE,
reportErr: e.Err,
EndCall: EndCall{
Status: callRejected,
Reason: livekit.DisconnectReason_MEDIA_FAILURE,
Report: e.Err,
},
returnErr: e,
}
switch {
case errors.Is(e.Err, sdp.ErrNoCommonMedia):
res.term = stats.ClientError("no-common-codec")
res.Term = stats.ClientError("no-common-codec")
case errors.Is(e.Err, sdp.ErrNoCommonCrypto):
res.term = stats.ClientError("encryption-required")
res.Term = stats.ClientError("encryption-required")
default:
res.term = stats.ClientError("sdp-error")
res.Term = stats.ClientError("sdp-error")
}
return res
}
Expand All @@ -89,10 +88,12 @@ func (e transactionTimeoutError) ClassifyInvite() inviteFailure {
reason = "no-final-response"
}
return inviteFailure{
status: callUnavailable,
term: stats.ClientError(reason),
reason: livekit.DisconnectReason_SIP_TRUNK_FAILURE,
reportErr: e, // keep so the customer sees their destination didn't complete
EndCall: EndCall{
Status: callUnavailable,
Term: stats.ClientError(reason),
Reason: livekit.DisconnectReason_SIP_TRUNK_FAILURE,
Report: e, // keep so the customer sees their destination didn't complete
},
returnErr: psrpc.NewError(psrpc.Canceled, e),
}
}
Expand All @@ -107,106 +108,108 @@ func classifyInviteError(err error) inviteFailure {
}

res := inviteFailure{
status: callDropped,
term: stats.ServerError("invite-failed"),
reason: livekit.DisconnectReason_UNKNOWN_REASON,
reportErr: err,
EndCall: EndCall{
Status: callDropped,
Term: stats.ServerError("invite-failed"),
Reason: livekit.DisconnectReason_UNKNOWN_REASON,
Report: err,
},
returnErr: err,
}

if sipStatus, ok := errors.AsType[*livekit.SIPStatus](err); ok {
code := int(sipStatus.Code)
switch code {
case int(sip.StatusUnauthorized), int(sip.StatusProxyAuthRequired):
res.status, res.term, res.reason = callRejected, stats.ClientError("auth-required"), livekit.DisconnectReason_USER_REJECTED
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("auth-required"), livekit.DisconnectReason_USER_REJECTED
res.Report = nil
case int(sip.StatusForbidden):
res.status, res.term, res.reason = callRejected, stats.ClientError("forbidden"), livekit.DisconnectReason_USER_REJECTED
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("forbidden"), livekit.DisconnectReason_USER_REJECTED
res.Report = nil
case int(sip.StatusNotFound):
res.status, res.term, res.reason = callUnavailable, stats.ClientError("not-found"), livekit.DisconnectReason_USER_UNAVAILABLE
res.reportErr = nil
res.Status, res.Term, res.Reason = callUnavailable, stats.ClientError("not-found"), livekit.DisconnectReason_USER_UNAVAILABLE
res.Report = nil
case int(sip.StatusRequestTimeout):
res.status, res.term, res.reason = callUnavailable, stats.ClientError("request-timeout"), livekit.DisconnectReason_USER_UNAVAILABLE
res.reportErr = nil
res.Status, res.Term, res.Reason = callUnavailable, stats.ClientError("request-timeout"), livekit.DisconnectReason_USER_UNAVAILABLE
res.Report = nil
case int(sip.StatusTemporarilyUnavailable):
res.status, res.term, res.reason = callUnavailable, stats.ClientError("unavailable"), livekit.DisconnectReason_USER_UNAVAILABLE
res.reportErr = nil
res.Status, res.Term, res.Reason = callUnavailable, stats.ClientError("unavailable"), livekit.DisconnectReason_USER_UNAVAILABLE
res.Report = nil
case int(sip.StatusBusyHere):
res.status, res.term, res.reason = callRejected, stats.ClientError("busy"), livekit.DisconnectReason_USER_REJECTED
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("busy"), livekit.DisconnectReason_USER_REJECTED
res.Report = nil
case int(sip.StatusNotAcceptableHere):
res.status, res.term, res.reason = callRejected, stats.ClientError("not-acceptable"), livekit.DisconnectReason_USER_REJECTED
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("not-acceptable"), livekit.DisconnectReason_USER_REJECTED
res.Report = nil
default:
switch {
case code >= 400 && code < 500:
res.status, res.term, res.reason = callRejected, stats.ClientError(fmt.Sprintf("client-error-%d", code)), livekit.DisconnectReason_USER_UNAVAILABLE
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError(fmt.Sprintf("client-error-%d", code)), livekit.DisconnectReason_USER_UNAVAILABLE
res.Report = nil
case code >= 500 && code < 600:
// Some upstreams (notably Twilio) return a 5xx when the customer's own trunk exceeds its configured CPS or
// concurrent-call cap. That's a customer-side rate limit, not upstream infrastructure breakage, so it must not count
// against the server-error SLI. Match on the response body — brittle, so kept narrow to the known phrases.
body := strings.ToLower(sipStatus.GetStatus())
switch {
case strings.Contains(body, "cps limit exceeded"):
res.status, res.term, res.reason = callRejected, stats.ClientError("cps-limit-exceeded"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("cps-limit-exceeded"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
// keep reportErr so the customer can see they hit their cap
case strings.Contains(body, "concurrent call limit exceeded"):
res.status, res.term, res.reason = callRejected, stats.ClientError("concurrent-limit-exceeded"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("concurrent-limit-exceeded"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
// keep reportErr so the customer can see they hit their cap
default:
// Carrier-side 5xx; keep reportErr for the detail.
res.status, res.term, res.reason = callDropped, stats.UpstreamError(fmt.Sprintf("upstream-server-error-%d", code)), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.Status, res.Term, res.Reason = callDropped, stats.UpstreamError(fmt.Sprintf("upstream-server-error-%d", code)), livekit.DisconnectReason_SIP_TRUNK_FAILURE
}
case code >= 600 && code < 700:
res.status, res.term, res.reason = callRejected, stats.ClientError(fmt.Sprintf("global-decline-%d", code)), livekit.DisconnectReason_USER_REJECTED
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError(fmt.Sprintf("global-decline-%d", code)), livekit.DisconnectReason_USER_REJECTED
res.Report = nil
}
}
return res
}

if errors.Is(err, ErrSIPRequestTimeout) {
res.status, res.term, res.reason = callUnavailable, stats.ClientError("no-answer"), livekit.DisconnectReason_USER_UNAVAILABLE
res.reportErr = nil
res.Status, res.Term, res.Reason = callUnavailable, stats.ClientError("no-answer"), livekit.DisconnectReason_USER_UNAVAILABLE
res.Report = nil
return res
}

// Context cancellation / deadline. Check before *net.OpError because an
// op error can wrap a context error, and the context cause is more
// informative.
if errors.Is(err, context.DeadlineExceeded) {
res.status, res.term, res.reason = callDropped, stats.ServerError("deadline-exceeded"), livekit.DisconnectReason_UNKNOWN_REASON
res.Status, res.Term, res.Reason = callDropped, stats.ServerError("deadline-exceeded"), livekit.DisconnectReason_UNKNOWN_REASON
res.returnErr = psrpc.NewError(psrpc.DeadlineExceeded, err)
return res
}
if errors.Is(err, context.Canceled) {
res.status, res.term, res.reason = callRejected, stats.ClientError("canceled"), livekit.DisconnectReason_USER_UNAVAILABLE
res.reportErr = nil
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("canceled"), livekit.DisconnectReason_USER_UNAVAILABLE
res.Report = nil
res.returnErr = psrpc.NewError(psrpc.Canceled, err)
return res
}

// Specific net error types before *net.OpError (which wraps them).
if _, ok := errors.AsType[*net.AddrError](err); ok {
res.status, res.term, res.reason = callDropped, stats.ClientError("address-error"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.Status, res.Term, res.Reason = callDropped, stats.ClientError("address-error"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.returnErr = psrpc.NewError(psrpc.InvalidArgument, err)
return res
}
if _, ok := errors.AsType[*net.DNSError](err); ok {
res.status, res.term, res.reason = callDropped, stats.ClientError("dns-resolution"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.Status, res.Term, res.Reason = callDropped, stats.ClientError("dns-resolution"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.returnErr = psrpc.NewError(psrpc.InvalidArgument, err)
return res
}
if _, ok := errors.AsType[*net.OpError](err); ok {
res.status, res.term, res.reason = callDropped, stats.ServerError("network-error"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.Status, res.Term, res.Reason = callDropped, stats.ServerError("network-error"), livekit.DisconnectReason_SIP_TRUNK_FAILURE
res.returnErr = psrpc.NewError(psrpc.Unavailable, err)
return res
}

if errors.Is(err, ErrAuthMaxRetry) || errors.Is(err, ErrAuthMissingCreds) || errors.Is(err, ErrAuthNoHeader) {
res.status, res.term, res.reason = callRejected, stats.ClientError("auth-failed"), livekit.DisconnectReason_USER_REJECTED
res.Status, res.Term, res.Reason = callRejected, stats.ClientError("auth-failed"), livekit.DisconnectReason_USER_REJECTED
// keep reportErr so the auth detail is recorded
return res
}
Expand Down
10 changes: 5 additions & 5 deletions pkg/sip/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,13 @@ func TestClassifyInviteError(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := classifyInviteError(tc.err)
require.Equal(t, tc.wantStatus, res.status, "status")
require.Equal(t, tc.wantTerm, res.term, "termination")
require.Equal(t, tc.wantReason, res.reason, "disconnect reason")
require.Equal(t, tc.wantStatus, res.Status, "status")
require.Equal(t, tc.wantTerm, res.Term, "termination")
require.Equal(t, tc.wantReason, res.Reason, "disconnect reason")
if tc.wantReport {
require.NotNil(t, res.reportErr, "reportErr expected non-nil")
require.NotNil(t, res.Report, "reportErr expected non-nil")
} else {
require.Nil(t, res.reportErr, "reportErr expected nil")
require.Nil(t, res.Report, "reportErr expected nil")
}
})
}
Expand Down
Loading
Loading