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.