DEV Community

ahmet gedik
ahmet gedik

Posted on

How to Handle YouTube API Errors Gracefully in PHP

Working with the YouTube Data API v3 is essential for video platforms like TrendVidStream. But the API has quirks: rate limits, quota exhaustion, intermittent failures, and region-specific errors. Here's how to handle them all.

Common Error Types

HTTP Code Error Cause Recovery
400 Bad Request Invalid parameter Fix request
403 Forbidden Quota exceeded or API key invalid Wait or rotate key
404 Not Found Video/resource deleted Skip and log
429 Too Many Requests Rate limit hit Exponential backoff
500 Internal Server Error YouTube backend issue Retry with backoff
503 Service Unavailable Temporary outage Retry with backoff

Error-Resilient YouTube Client

<?php

class YouTubeClient
{
    private string $apiKey;
    private int $maxRetries;
    private int $quotaUsed = 0;
    private const DAILY_QUOTA = 10000; // Default free tier

    public function __construct(string $apiKey, int $maxRetries = 3)
    {
        $this->apiKey = $apiKey;
        $this->maxRetries = $maxRetries;
    }

    public function getTrending(string $region, int $maxResults = 50): array
    {
        return $this->request('videos', [
            'part' => 'snippet,contentDetails,statistics',
            'chart' => 'mostPopular',
            'regionCode' => $region,
            'maxResults' => $maxResults,
        ]);
    }

    public function getCategories(string $region): array
    {
        return $this->request('videoCategories', [
            'part' => 'snippet',
            'regionCode' => $region,
        ]);
    }

    private function request(string $endpoint, array $params): array
    {
        $params['key'] = $this->apiKey;
        $url = "https://www.googleapis.com/youtube/v3/{$endpoint}?" . http_build_query($params);

        $attempt = 0;
        $lastError = null;

        while ($attempt < $this->maxRetries) {
            $attempt++;

            try {
                $result = $this->doRequest($url);
                $this->trackQuota($endpoint);
                return $result;
            } catch (YouTubeQuotaException $e) {
                // Quota exceeded - do not retry
                error_log("YouTube quota exceeded: {$e->getMessage()}");
                throw $e;
            } catch (YouTubeRateLimitException $e) {
                // Rate limited - backoff and retry
                $delay = $this->calculateBackoff($attempt);
                error_log("YouTube rate limited, waiting {$delay}s (attempt {$attempt})");
                sleep($delay);
                $lastError = $e;
            } catch (YouTubeServerException $e) {
                // Server error - backoff and retry
                $delay = $this->calculateBackoff($attempt);
                error_log("YouTube server error, waiting {$delay}s (attempt {$attempt})");
                sleep($delay);
                $lastError = $e;
            } catch (YouTubeClientException $e) {
                // Client error - do not retry
                error_log("YouTube client error: {$e->getMessage()}");
                throw $e;
            }
        }

        throw new YouTubeException(
            "Failed after {$this->maxRetries} attempts: " . $lastError?->getMessage()
        );
    }

    private function doRequest(string $url): array
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($curlError) {
            throw new YouTubeServerException("cURL error: $curlError");
        }

        $data = json_decode($response, true);

        if ($httpCode === 403) {
            $reason = $data['error']['errors'][0]['reason'] ?? 'unknown';
            if ($reason === 'quotaExceeded' || $reason === 'dailyLimitExceeded') {
                throw new YouTubeQuotaException("Quota exceeded: $reason");
            }
            throw new YouTubeClientException("Forbidden: $reason");
        }

        if ($httpCode === 429) {
            throw new YouTubeRateLimitException('Rate limit exceeded');
        }

        if ($httpCode >= 500) {
            throw new YouTubeServerException("Server error: $httpCode");
        }

        if ($httpCode >= 400) {
            $message = $data['error']['message'] ?? "HTTP $httpCode";
            throw new YouTubeClientException($message);
        }

        return $data;
    }

    private function calculateBackoff(int $attempt): int
    {
        // Exponential backoff with jitter
        $base = min(pow(2, $attempt), 32);
        return $base + random_int(0, $base);
    }

    private function trackQuota(string $endpoint): void
    {
        // Approximate quota costs
        $costs = [
            'videos' => 1,
            'videoCategories' => 1,
            'search' => 100,
            'channels' => 1,
        ];
        $this->quotaUsed += $costs[$endpoint] ?? 1;
    }

    public function getQuotaUsed(): int
    {
        return $this->quotaUsed;
    }

    public function isQuotaAvailable(): bool
    {
        return $this->quotaUsed < self::DAILY_QUOTA;
    }
}
Enter fullscreen mode Exit fullscreen mode

Exception Hierarchy

<?php

class YouTubeException extends \RuntimeException {}
class YouTubeClientException extends YouTubeException {} // 4xx errors
class YouTubeServerException extends YouTubeException {} // 5xx errors
class YouTubeQuotaException extends YouTubeClientException {} // Quota exceeded
class YouTubeRateLimitException extends YouTubeClientException {} // Rate limited
Enter fullscreen mode Exit fullscreen mode

Usage in Cron Fetcher

<?php

$client = new YouTubeClient(getenv('YOUTUBE_API_KEY'));
$regions = ['US', 'GB', 'AE', 'DK', 'FI', 'CH', 'BE', 'CZ'];

foreach ($regions as $region) {
    if (!$client->isQuotaAvailable()) {
        echo "Quota exhausted, stopping.\n";
        break;
    }

    try {
        $videos = $client->getTrending($region);
        $count = count($videos['items'] ?? []);
        echo "[$region] Fetched $count videos\n";

        foreach ($videos['items'] as $item) {
            $db->upsertVideo(normalizeVideo($item, $region));
        }
    } catch (YouTubeQuotaException $e) {
        echo "Quota exceeded, stopping all fetches.\n";
        break;
    } catch (YouTubeException $e) {
        echo "[$region] Error: {$e->getMessage()}, skipping.\n";
        continue;
    }

    // Rate limiting between regions
    usleep(500000); // 500ms
}

echo "Quota used: {$client->getQuotaUsed()} units\n";
Enter fullscreen mode Exit fullscreen mode

This error handling approach keeps TrendVidStream running reliably across all 8 regions, gracefully handling YouTube API issues without dropping data or crashing the fetch pipeline.

Top comments (0)