Skip to content

Commit 36ede87

Browse files
authored
TEL-392: cache the drop and respond to retried failed calls (#694)
* TEL-392: cache the drop and respond to retried failed calls * add config knob
1 parent b0e1bd2 commit 36ede87

4 files changed

Lines changed: 128 additions & 0 deletions

File tree

pkg/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ type Config struct {
116116
// Setting it to true makes SIP server silently drop INVITE requests if it gets a negative Auth or Dispatch response.
117117
// Doing so hides our SIP endpoint from (a low effort) port scanners.
118118
HideInboundPort bool `yaml:"hide_inbound_port"`
119+
// DisableRejectedInviteCache turns off the per-server cache that replays
120+
// a final INVITE rejection (keyed by Call-ID + From-tag) for retries
121+
// reusing the same identifiers.
122+
DisableRejectedInviteCache bool `yaml:"disable_rejected_invite_cache"`
119123
// AddRecordRoute forces SIP to add Record-Route headers to the responses.
120124
AddRecordRoute bool `yaml:"add_record_route"`
121125

pkg/sip/inbound.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,17 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE
362362
log := cc.log.WithValues("transport", tr, "tid", tid.String())
363363
cc.log = log
364364

365+
// Replay cached final rejection for retries reusing the same Call-ID +
366+
// From-tag (e.g. provider-level failover after a 4xx). Skips creating
367+
// a duplicate call object and the OnSessionEnd side-effects that follow.
368+
if s.rejectedInvites != nil {
369+
if prev, ok := s.rejectedInvites.Get([2]string{cc.SIPCallID(), string(cc.Tag())}); ok {
370+
log.Debugw("replaying cached INVITE rejection", "status", prev.status, "reason", prev.reason)
371+
cc.RespondAndDrop(prev.status, prev.reason)
372+
return nil
373+
}
374+
}
375+
365376
log.Infow("processing invite")
366377

367378
s.cmu.RLock()
@@ -1720,6 +1731,15 @@ func (c *sipInbound) RespondAndDrop(status sip.StatusCode, reason string) {
17201731
c.stopRinging()
17211732
c.respond(status, reason)
17221733
c.drop()
1734+
// Cache the response so a retry reusing the same Call-ID + From-tag
1735+
// (e.g. provider failover after a 4xx) gets the cached reply replayed
1736+
// instead of running through the handler again.
1737+
if c.s != nil && c.s.rejectedInvites != nil && status >= 300 && c.sipCallID != "" {
1738+
c.s.rejectedInvites.Add(
1739+
[2]string{c.sipCallID, string(c.tag)},
1740+
rejectedInviteResponse{status: status, reason: reason},
1741+
)
1742+
}
17231743
}
17241744

17251745
func (c *sipInbound) Address() sip.Uri {

pkg/sip/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ type Server struct {
157157
cmu sync.RWMutex
158158
byLocalTag map[LocalTag]*inboundCall
159159
provisionalInvites *expirable.LRU[[2]string, LocalTag]
160+
rejectedInvites *expirable.LRU[[2]string, rejectedInviteResponse]
160161

161162
infos struct {
162163
sync.Mutex
@@ -178,6 +179,11 @@ type inProgressInvite struct {
178179
authResolved atomic.Bool
179180
}
180181

182+
type rejectedInviteResponse struct {
183+
status sip.StatusCode
184+
reason string
185+
}
186+
181187
type ServerOption func(s *Server)
182188

183189
func WithGetRoomServer(fn GetRoomFunc) ServerOption {
@@ -208,6 +214,10 @@ func NewServer(region string, conf *config.Config, log logger.Logger, mon *stats
208214
byLocalTag: make(map[LocalTag]*inboundCall),
209215
provisionalInvites: expirable.NewLRU[[2]string, LocalTag](maxCallCache, nil, callCacheTTL),
210216
}
217+
// Initialize the rejected-invite replay cache unless explicitly disabled.
218+
if !conf.DisableRejectedInviteCache {
219+
s.rejectedInvites = expirable.NewLRU[[2]string, rejectedInviteResponse](maxCallCache, nil, callCacheTTL)
220+
}
211221
for _, option := range options {
212222
option(s)
213223
}

pkg/sip/service_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"math/rand"
88
"net/netip"
99
"sync"
10+
"sync/atomic"
1011
"testing"
1112
"time"
1213

@@ -242,6 +243,99 @@ func TestService_AuthDrop(t *testing.T) {
242243
})
243244
}
244245

246+
// TestService_RejectedInviteCacheReplay verifies that a second INVITE
247+
// reusing the same Call-ID and From-tag after a final 4xx response gets
248+
// the cached response replayed without invoking the auth/dispatch
249+
// handlers a second time. This guards the dedup that absorbs
250+
// provider-level retries (same Call-ID + From-tag, new SIP transaction)
251+
// after we've already sent a terminal rejection.
252+
func TestService_RejectedInviteCacheReplay(t *testing.T) {
253+
const (
254+
fromUser = "caller@example.com"
255+
toUser = "callee@example.com"
256+
callID = "rejected-invite-replay-test@example.com"
257+
fromTag = "fixed-from-tag-replay"
258+
)
259+
260+
var authCalls, dispatchCalls atomic.Int32
261+
262+
h := &TestHandler{
263+
GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) {
264+
authCalls.Add(1)
265+
return AuthInfo{Result: AuthAccept}, nil
266+
},
267+
DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch {
268+
dispatchCalls.Add(1)
269+
return CallDispatch{Result: DispatchNoRuleReject}
270+
},
271+
OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, callInfo *livekit.SIPCallInfo, reason string) {
272+
// no-op
273+
},
274+
}
275+
276+
sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin
277+
localIP, err := config.GetLocalIP()
278+
require.NoError(t, err)
279+
sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort)
280+
281+
mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9})
282+
require.NoError(t, err)
283+
284+
log := logger.LogRLogger(logr.Discard())
285+
s, err := NewService("", &config.Config{
286+
SIPPort: sipPort,
287+
SIPPortListen: sipPort,
288+
RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax},
289+
}, mon, log, func(projectID string) rpc.IOInfoClient { return nil })
290+
require.NoError(t, err)
291+
require.NotNil(t, s)
292+
s.SetHandler(h)
293+
require.NoError(t, s.Start())
294+
t.Cleanup(s.Stop)
295+
296+
ua, err := sipgo.NewUA(sipgo.WithUserAgent(fromUser),
297+
sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))))
298+
require.NoError(t, err)
299+
client, err := sipgo.NewClient(ua)
300+
require.NoError(t, err)
301+
302+
offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone)
303+
require.NoError(t, err)
304+
offerData, err := offer.SDP.Marshal()
305+
require.NoError(t, err)
306+
307+
sendInvite := func() *sip.Response {
308+
recipient := sip.Uri{User: toUser, Host: sipServerAddress}
309+
req := sip.NewRequest(sip.INVITE, recipient)
310+
req.SetDestination(sipServerAddress)
311+
req.SetBody(offerData)
312+
req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp"))
313+
req.AppendHeader(sip.NewHeader("Call-ID", callID))
314+
req.AppendHeader(&sip.FromHeader{
315+
DisplayName: fromUser,
316+
Address: sip.Uri{User: fromUser, Host: sipServerAddress},
317+
Params: sip.HeaderParams{{K: "tag", V: fromTag}},
318+
})
319+
tx, err := client.TransactionRequest(req)
320+
require.NoError(t, err)
321+
t.Cleanup(tx.Terminate)
322+
return getFinalResponseOrFail(t, tx, req)
323+
}
324+
325+
// First INVITE: full handler invocation, 404 from DispatchNoRuleReject.
326+
res1 := sendInvite()
327+
require.Equal(t, sip.StatusCode(404), res1.StatusCode)
328+
require.Equal(t, int32(1), authCalls.Load())
329+
require.Equal(t, int32(1), dispatchCalls.Load())
330+
331+
// Second INVITE with the same Call-ID + From-tag should be served from
332+
// the cache: same 404, but handlers must NOT be invoked again.
333+
res2 := sendInvite()
334+
require.Equal(t, sip.StatusCode(404), res2.StatusCode)
335+
require.Equal(t, int32(1), authCalls.Load(), "auth handler must not be re-invoked on replay")
336+
require.Equal(t, int32(1), dispatchCalls.Load(), "dispatch handler must not be re-invoked on replay")
337+
}
338+
245339
func TestService_OnSessionEnd(t *testing.T) {
246340
const (
247341
expectedCallID = "test-call-id"

0 commit comments

Comments
 (0)