0

I have built a Twilio call center which uses a TwiML app to deliver events to my PHP server. I've been monitoring the system over the last year and I am concerned that a few calls seem to arrive with invalid status. This seems to be a clear bug in the way that Twilio is handling inbound calls.

Here is my TwiML App setup...

TwiML App setup

I am using a GET on https://[site]/anon/callcenter/voice.xml for the Request URL and a POST on https://[site]/anon/callcenter/app/voice/status for the voice status callback.

Here is a typical call that gets missed...

enter image description here

There are only two messages, a get from the request URL and a POST to the status URL. There should be many more. The get to the request URL has a call status of "ringing" and the post to the status URL has a status of "no-answer".

Here is the detailed get request to the request URL...

enter image description here

Here is the detailed post request to the status URL...

enter image description here

Notice that the timestamp of both requests is identical. These calls are creating problems for me. When the request to the request URL arrives, I enqueue the call and create a record with the status as ringing. The next event I am expecting is "completed" when the caller hangs up. The completed message never arrives, because the no-answer has already been delivered. The net effect is that the call remains "stuck in ringing".

So this seems that a clear failure of the Twilio API. If the caller dialed the call center and then hung up immediately, I should get a completed status. "no-answer" seems to imply that Twilio never attempted to route the call.

Let me emphasize, that the system is working reasonably well. We have a moderate volume of inbound calls and most of them are handled properly. About 5 calls a month exhibit this "stuck in ringing" behavior. Would really like to get this cleaned up.

6
  • Is this a question or a bug report?
    – DarkBee
    Commented Jun 28 at 15:57
  • @DarkBee This seems to me to be an obvious bug. Hard to say for sure without any documentation on what "no-answer" means.
    – AQuirky
    Commented Jun 28 at 16:33
  • Unfortunately SO is not the place to be posting a bug-report - Can I support my product on this site?
    – DarkBee
    Commented Jun 29 at 5:59
  • @DarkBee this is not a bug report.
    – AQuirky
    Commented Jul 1 at 14:27
  • Ok @DarkBee I just got a response from Twilio support on a ticket where I referenced this SO post and they say this is not a BUG , it is a FEATURE. I expected this, which is why I posted this here is in the first place. So my question deserves an answer. Twilio support did not provide one.
    – AQuirky
    Commented Jul 1 at 14:37

1 Answer 1

0

Right...

No help from Twilio support. "It is what it is" is basically their response.

To summarize, the basic problem here is that, under certain conditions, the call completion request (no-answer) arrives before the call initiation request (ringing).

This creates a problem in my system because a call record is opened on call initiation and then closed on call completion. If the call completion arrives before the call initiation then the call completion request is ignored and follow on call initiation message opens a call record which is never closed. This results in a situation where the call is "stuck in ringing".

This does not happen that often. I've seen 5 in the last week on significant call volume. It happens often enough that I need to do something.

My approach to addressing this is when the call completion request front runs the call initiation request, I cache the call in Redis using the call sid as the key. Then when handling the call initialize request, I check the cache and if there is a hit, then I simply close the newly created call record with a missed call status.

Here is my class to cache the call...

<?php

namespace App\Database;

use App\Util\Util;

class CachedCall implements \JsonSerializable
{
    private $timestamp;
    private $callSid;
    private $activity;
    private $status;
    public function __construct($timestamp, $callSid, $activity, $status)
    {
        $this->timestamp = $timestamp;
        $this->callSid = $callSid;
        $this->activity = $activity;
        $this->status = $status;
    }   
    public function getTimestamp()
    {
        return $this->timestamp;
    }
    public function getCallSid()
    {
        return $this->callSid;
    }
    public function getActivity()
    {
        return $this->activity;
    }
    public function getStatus()
    {
        return $this->status;
    }
    public function jsonSerialize()
    {
        return [
            'timestamp' => Util::getTimestamp($this->timestamp),
            'call_sid' => $this->callSid,
            'activity' => $this->activity,
            'status' => $this->status
        ];
    }
    public function __toString()
    {
        return "Call: " . $this->callSid . " Activity: " . $this->activity . " Status: " . $this->status . " Timestamp: " . $this->timestamp->format('Y-m-d H:i:s');
    }
}

Here is the code in controller to handle the app voice status request...

public function appVoiceStatusCallback(
    Request $request,
    EntityManagerInterface $em,
    EnCallCenterCallRepository $callRepo,
    LoggerInterface $callcenterLogger
    ) : Response
{
    $callcenterLogger->info('in appVoiceStatusCallback');
    $callcenterLogger->info(json_encode($request->request->all()));
    $direction = $request->request->get('Direction');
    $parentCallSid = $request->request->get('ParentCallSid');
    $callSid = $request->request->get('CallSid');
    $rawCallStatus = $request->request->get('CallStatus');
    $callStatus = EnCallCenterCall::interpretRawCallStatus($rawCallStatus);
    try {
        if($direction == 'inbound' && $callStatus == CallCenterStatus::COMPLETED) {
            $call = $callRepo->findOneBy(['callSid' => $callSid]);
            if($call) {
                $queueEntry = $call->getQueueEntry();
                if($queueEntry->getAgent() == null) {
                    $pubSubService = $this->getPubSubService();
                    $queueEntry->updateCallStatus(CallCenterStatus::MISSED_CALL);
                    $em->flush();
                    $pubSubService->publish('server','activity-queue',json_encode($queueEntry));
                }
            }
        }
        $cacheItem = $this->cache->getItem($callSid);
        $cachedCall = new CachedCall(new \DateTimeImmutable, $callSid, CallCenterActivity::INBOUND_CALL, $callStatus);
        $cacheItem->set($cachedCall);
        $cacheItem->expiresAfter(3600);
        $this->cache->save($cacheItem);                     
    } catch(\Exception $ex) {
        return new Response('Exception in voice status callback: '.$ex->getMessage(),400);
    }   
    return new Response();
}

Note that I simply put every call in the cache with an expiration of 1 hour.

Then in the request for voice.xml, after opening the call record I call a function to check for the stuck-in-ringing situation...

private function checkNoAnswerCall(EnCallCenterQueueEntry $queueEntry, EnCallCenterCall $call, LoggerInterface $callcenterLogger, EntityManagerInterface $em)
{
    $callSid = $call->getCallSid();
    $cacheItem = $this->cache->getItem($callSid);
    if($cacheItem->isHit()) {
        $cachedCall = $cacheItem->get();
        $callcenterLogger->info('cached no-answer call = ' . $cachedCall);
        $event = new EnCallCenterEvent(EnCallCenterEvent::EVENT_NO_ANSWER_CALL, "Call {$cachedCall->getCallSid()} received with no-answer status.  Creating missed call.");               
        $em->persist($event);
        $queueEntry->updateCallStatus(CallCenterStatus::MISSED_CALL);
        $em->flush();
    }
}

Note that I don't check the status of the call. I don't have to. My voice status callback is only sending completion events. So if a cache entry exists when I get an inbound request, I know immediately there is a problem and simply set the call to missed.

Finally, here is how to setup the Redis adaptor in Symfony/PHP which I struggled with a bit. In cache.yaml...

framework:
    cache:
        prefix_seed: xxx/yyy
        app: cache.adapter.redis

In services.yaml...

services:
    Redis:
      class: Redis
      calls:
        - connect:
            - '%env(REDIS_HOST)%'
            - '%env(int:REDIS_PORT)%'
        - auth:
            - '%env(REDIS_PASSWORD)%'

In the controller...

public function __construct(
    \Redis $redis
)
    
{
    $this->cache = new RedisAdapter($redis);
}

I am not completely convinced this is the proper solution for a couple of reasons...

(1) There is no way to duplicate this issue in development. Twilio support could not tell me what the "no-answer" status meant. They linked me to a table of call statuses which were entirely incompatible with the situation. "no-answer" in this table was documented as "Twilio dialed the number but no one answered before the timeout parameter value elapsed." No Twilio did not dial the number! It arrived at the controller with a status of no-answer before any dialing was done. My initial theory was this was caused the the caller immediately hanging up after dialing. However these calls result in a status of "canceled". This matches with the description in the call status table: "incoming call was disconnected by the calling party" (2) I'm not convinced the window is closed on this completion-before-initiation sequence. If the completion and initiation arrive at the same time there will be a race condition.

So in closing, I am still not happy with this situation. How can it be that the call completion arrives before the call initiation? It is true that these requests are asynchronous. Normally there would a sequence number or some other mechanism to sort it out. The call status request has a sequence number but the request for voice.xml does not. As a result I have to jump through hoops to address a situation that should never occur.

Not the answer you're looking for? Browse other questions tagged or ask your own question.