Series 8 — Part 6 of 7

The TTS → conversion → upload → send pipeline has three external services that can fail independently. Silent failures leave users with no response. This article covers per-step logging with fallback chains, the text fallback for audio failures, and the circuit breaker pattern.

The Three-Failure-Point Pipeline

function send_audio_with_fallback(string $recipient, string $text, string $correlationId): void
{
    $log = new StructuredLogger('/var/log/app/audio.log', $correlationId, IS_PROD);

    // Step 1: TTS generation
    try {
        $wavBytes = generate_tts($text);
        $log->info('tts.success', ['bytes' => strlen($wavBytes)]);
    } catch (\Exception $e) {
        $log->error('tts.failed', ['error' => $e->getMessage()]);
        // Fallback: send text directly
        send_whatsapp_text($recipient, $text);
        $log->info('text.fallback.sent');
        return;
    }

    // Step 2: Format conversion
    $tmpWav = $tmpOgg = null;
    try {
        $tmpWav = save_temp($wavBytes, '.wav');
        $tmpOgg = convert_wav_to_ogg($tmpWav);
        $log->info('conversion.success', ['ogg_bytes' => filesize($tmpOgg)]);
    } catch (\Exception $e) {
        $log->error('conversion.failed', ['error' => $e->getMessage()]);
        send_whatsapp_text($recipient, $text);
        $log->info('text.fallback.sent');
        return;
    } finally {
        if ($tmpWav && file_exists($tmpWav)) unlink($tmpWav);
    }

    // Step 3: Upload + send
    try {
        $mediaId = upload_to_meta($tmpOgg, 'audio/ogg; codecs=opus');
        $log->info('upload.success', ['media_id' => substr($mediaId, 0, 8) . '…']);
        send_whatsapp_audio($recipient, $mediaId);
        $log->info('audio.sent');
    } catch (\Exception $e) {
        $log->error('send.failed', ['error' => $e->getMessage()]);
        send_whatsapp_text($recipient, $text);
        $log->info('text.fallback.sent');
    } finally {
        if ($tmpOgg && file_exists($tmpOgg)) unlink($tmpOgg);
    }
}

Circuit Breaker Pattern

class CircuitBreaker
{
    private const FAILURE_THRESHOLD = 5;
    private const RESET_TIMEOUT_SEC = 300;

    public function isOpen(string $service, PDO $pdo): bool
    {
        $stmt = $pdo->prepare(
            'SELECT failure_count, last_failure_at FROM circuit_state WHERE service = ?'
        );
        $stmt->execute([$service]);
        $state = $stmt->fetch();

        if (!$state) return false;

        // If past reset timeout, close the circuit automatically
        $sinceLastFailure = time() - strtotime($state['last_failure_at']);
        if ($sinceLastFailure > self::RESET_TIMEOUT_SEC) {
            $this->reset($service, $pdo);
            return false;
        }

        return $state['failure_count'] >= self::FAILURE_THRESHOLD;
    }

    public function recordFailure(string $service, PDO $pdo): void
    {
        $pdo->prepare(
            'INSERT INTO circuit_state (service, failure_count, last_failure_at)
             VALUES (?, 1, NOW())
             ON DUPLICATE KEY UPDATE
               failure_count = failure_count + 1,
               last_failure_at = NOW()'
        )->execute([$service]);
    }
}

What to Watch For

  • Per-step logging is the only way to know which step failed — Without step-level logs, "audio send failed" could mean TTS crashed, FFmpeg crashed, Meta upload failed, or the send call failed. Log every step.
  • Text fallback is not optional — A pipeline that fails silently leaves the user waiting indefinitely. Always send a text fallback if audio fails.
  • Circuit breaker state in the database — Redis is faster, but if Redis is down, you need the circuit breaker most. Storing circuit state in MySQL means the fallback mechanism works even during a cache outage.