Effective error handling is essential when building robust PHP applications with Guzzle HTTP client. Poor error handling can lead to application crashes, poor user experience, and difficult debugging. This guide covers comprehensive best practices for handling errors in Guzzle applications.
Understanding Guzzle's Exception Hierarchy
Guzzle uses a well-structured exception hierarchy that allows for granular error handling:
use GuzzleHttp\Exception\RequestException; // Base exception
use GuzzleHttp\Exception\ClientException; // 4xx HTTP errors
use GuzzleHttp\Exception\ServerException; // 5xx HTTP errors
use GuzzleHttp\Exception\ConnectException; // Network connection errors
use GuzzleHttp\Exception\TooManyRedirectsException; // Redirect limit exceeded
use GuzzleHttp\Exception\BadResponseException; // Base for HTTP error responses
Exception Types and When They Occur
- RequestException: Base exception for all request-related errors
- ClientException: 400-499 HTTP status codes (client errors like 404, 401)
- ServerException: 500-599 HTTP status codes (server errors like 500, 503)
- ConnectException: Network issues (DNS resolution, connection timeouts)
- TooManyRedirectsException: Exceeding maximum redirect limit
- TransferException: Data transfer problems
1. Implement Comprehensive Try-Catch Blocks
Use specific exception catching to handle different error types appropriately:
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
$client = new Client();
try {
$response = $client->request('GET', 'https://api.example.com/users/123');
$data = json_decode($response->getBody()->getContents(), true);
return $data;
} catch (ClientException $e) {
// Handle 4xx errors (client-side issues)
$statusCode = $e->getResponse()->getStatusCode();
if ($statusCode === 404) {
throw new UserNotFoundException('User not found');
} elseif ($statusCode === 401) {
throw new UnauthorizedException('Invalid authentication');
} else {
throw new ClientErrorException("Client error: {$statusCode}");
}
} catch (ServerException $e) {
// Handle 5xx errors (server-side issues)
$statusCode = $e->getResponse()->getStatusCode();
error_log("Server error {$statusCode}: " . $e->getMessage());
throw new ServiceUnavailableException('External service temporarily unavailable');
} catch (ConnectException $e) {
// Handle network connectivity issues
error_log("Connection error: " . $e->getMessage());
throw new NetworkException('Unable to connect to external service');
} catch (RequestException $e) {
// Handle other request-related errors
error_log("Request error: " . $e->getMessage());
throw new ExternalServiceException('External service request failed');
}
2. Disable HTTP Error Exceptions When Needed
By default, Guzzle throws exceptions for 4xx and 5xx responses. You can disable this behavior:
try {
$response = $client->request('GET', 'https://api.example.com/data', [
'http_errors' => false // Disable automatic exception throwing
]);
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$errorBody = $response->getBody()->getContents();
$errorData = json_decode($errorBody, true);
// Handle error based on status code and response content
switch ($statusCode) {
case 400:
throw new ValidationException($errorData['message'] ?? 'Bad request');
case 401:
throw new AuthenticationException('Unauthorized access');
case 429:
throw new RateLimitException('Rate limit exceeded');
default:
throw new ApiException("API error: {$statusCode}");
}
}
return json_decode($response->getBody()->getContents(), true);
} catch (RequestException $e) {
// Handle network and other non-HTTP errors
throw new NetworkException('Request failed: ' . $e->getMessage());
}
3. Implement Robust Retry Mechanisms
Use Guzzle's retry middleware for handling transient failures:
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
// Create custom retry decider
$retryDecider = function (
int $retries,
Request $request,
Response $response = null,
RequestException $exception = null
) {
// Limit the number of retries
if ($retries >= 3) {
return false;
}
// Retry on connection exceptions
if ($exception instanceof ConnectException) {
return true;
}
if ($response) {
// Retry on server errors and rate limiting
$statusCode = $response->getStatusCode();
return in_array($statusCode, [500, 502, 503, 504, 429]);
}
return false;
};
// Create custom delay function (exponential backoff)
$retryDelay = function (int $numberOfRetries, Response $response = null) {
// Check for Retry-After header
if ($response && $response->hasHeader('Retry-After')) {
$retryAfter = $response->getHeaderLine('Retry-After');
return is_numeric($retryAfter) ? (int)$retryAfter * 1000 : 1000;
}
// Exponential backoff: 1s, 2s, 4s
return (int) pow(2, $numberOfRetries) * 1000;
};
// Set up the handler stack
$handlerStack = HandlerStack::create();
$handlerStack->push(Middleware::retry($retryDecider, $retryDelay));
$client = new Client([
'handler' => $handlerStack,
'timeout' => 10,
'connect_timeout' => 5,
]);
4. Configure Appropriate Timeouts
Always set timeouts to prevent hanging requests:
$client = new Client([
'timeout' => 30.0, // Total request timeout
'connect_timeout' => 5.0, // Connection establishment timeout
'read_timeout' => 25.0, // Time to wait for response
]);
// Per-request timeout override
$response = $client->request('GET', 'https://slow-api.example.com/data', [
'timeout' => 60.0, // Override for this specific request
]);
5. Implement Comprehensive Logging
Create detailed logs for debugging and monitoring:
use Psr\Log\LoggerInterface;
class ApiClient
{
private Client $client;
private LoggerInterface $logger;
public function __construct(Client $client, LoggerInterface $logger)
{
$this->client = $client;
$this->logger = $logger;
}
public function makeRequest(string $method, string $url, array $options = [])
{
$startTime = microtime(true);
try {
$this->logger->info('Making HTTP request', [
'method' => $method,
'url' => $url,
'options' => $this->sanitizeOptions($options)
]);
$response = $this->client->request($method, $url, $options);
$duration = microtime(true) - $startTime;
$this->logger->info('HTTP request successful', [
'method' => $method,
'url' => $url,
'status_code' => $response->getStatusCode(),
'duration_ms' => round($duration * 1000, 2)
]);
return $response;
} catch (RequestException $e) {
$duration = microtime(true) - $startTime;
$context = [
'method' => $method,
'url' => $url,
'duration_ms' => round($duration * 1000, 2),
'error_message' => $e->getMessage()
];
if ($e->hasResponse()) {
$response = $e->getResponse();
$context['status_code'] = $response->getStatusCode();
$context['response_body'] = $response->getBody()->getContents();
}
$this->logger->error('HTTP request failed', $context);
throw $e;
}
}
private function sanitizeOptions(array $options): array
{
// Remove sensitive data from logging
if (isset($options['headers']['Authorization'])) {
$options['headers']['Authorization'] = '[REDACTED]';
}
return $options;
}
}
6. Create Custom Error Handling Middleware
Build middleware for centralized error handling:
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Promise\FulfilledPromise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
function createErrorHandlingMiddleware(LoggerInterface $logger = null)
{
return function (callable $handler) use ($logger) {
return function (RequestInterface $request, array $options) use ($handler, $logger) {
return $handler($request, $options)->then(
function (ResponseInterface $response) use ($request, $logger) {
// Log successful responses if needed
return $response;
},
function ($reason) use ($request, $logger) {
if ($reason instanceof RequestException) {
// Custom error handling logic
$url = (string) $request->getUri();
if ($logger) {
$logger->warning('HTTP request failed', [
'url' => $url,
'method' => $request->getMethod(),
'error' => $reason->getMessage()
]);
}
// Transform specific errors
if ($reason instanceof ConnectException) {
return new RejectedPromise(
new NetworkException('Network connectivity issue', 0, $reason)
);
}
}
return new RejectedPromise($reason);
}
);
};
};
}
// Apply the middleware
$stack = HandlerStack::create();
$stack->push(createErrorHandlingMiddleware($logger));
$client = new Client(['handler' => $stack]);
7. Implement Circuit Breaker Pattern
Prevent cascading failures with a circuit breaker:
class CircuitBreaker
{
private int $failureCount = 0;
private int $failureThreshold;
private int $timeout;
private ?int $lastFailureTime = null;
private string $state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
public function __construct(int $failureThreshold = 5, int $timeout = 60)
{
$this->failureThreshold = $failureThreshold;
$this->timeout = $timeout;
}
public function call(callable $callback)
{
if ($this->state === 'OPEN') {
if (time() - $this->lastFailureTime < $this->timeout) {
throw new CircuitBreakerOpenException('Circuit breaker is open');
}
$this->state = 'HALF_OPEN';
}
try {
$result = $callback();
$this->onSuccess();
return $result;
} catch (Exception $e) {
$this->onFailure();
throw $e;
}
}
private function onSuccess(): void
{
$this->failureCount = 0;
$this->state = 'CLOSED';
}
private function onFailure(): void
{
$this->failureCount++;
$this->lastFailureTime = time();
if ($this->failureCount >= $this->failureThreshold) {
$this->state = 'OPEN';
}
}
}
// Usage with Guzzle
$circuitBreaker = new CircuitBreaker();
try {
$response = $circuitBreaker->call(function () use ($client) {
return $client->request('GET', 'https://api.example.com/data');
});
} catch (CircuitBreakerOpenException $e) {
// Use fallback or cached data
return $this->getFallbackData();
}
8. Handle Specific Scenarios
Rate Limiting
try {
$response = $client->request('GET', 'https://api.example.com/data');
} catch (ClientException $e) {
if ($e->getResponse()->getStatusCode() === 429) {
$retryAfter = $e->getResponse()->getHeaderLine('Retry-After');
throw new RateLimitException("Rate limited. Retry after: {$retryAfter} seconds");
}
throw $e;
}
Authentication Errors
try {
$response = $client->request('GET', 'https://api.example.com/protected', [
'headers' => ['Authorization' => 'Bearer ' . $token]
]);
} catch (ClientException $e) {
if ($e->getResponse()->getStatusCode() === 401) {
// Attempt token refresh
$newToken = $this->refreshToken();
// Retry with new token
return $client->request('GET', 'https://api.example.com/protected', [
'headers' => ['Authorization' => 'Bearer ' . $newToken]
]);
}
}
9. Testing Error Handling
Create comprehensive tests for your error handling:
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
class ApiClientTest extends TestCase
{
public function testHandles404Error()
{
$mockResponse = new Response(404, [], '{"error": "Not found"}');
$mockException = new ClientException('Not found',
$this->createMock(RequestInterface::class),
$mockResponse
);
$mockClient = $this->createMock(Client::class);
$mockClient->method('request')->willThrowException($mockException);
$apiClient = new ApiClient($mockClient);
$this->expectException(UserNotFoundException::class);
$apiClient->getUser(123);
}
}
Key Takeaways
- Use specific exception catching for different error types
- Implement retry mechanisms for transient failures
- Set appropriate timeouts to prevent hanging requests
- Log comprehensive error information for debugging
- Consider disabling http_errors when you need custom response handling
- Implement circuit breakers for external service resilience
- Handle authentication and rate limiting scenarios specifically
- Test your error handling thoroughly
By following these best practices, you'll build more resilient PHP applications that gracefully handle the inevitable network and service failures that occur in distributed systems.