Files
coolify/app/Exceptions/CloudProviderApiException.php
Andras Bacsai 16c6de823b fix(cloud-providers): show why Hetzner, DigitalOcean, and Vultr API requests fail
Provider errors now return 502 with a safe message, the provider status, and
the provider error code instead of a bare 500. Hetzner no longer sends a create
request again after a 5xx, and a VPS that Coolify did not save is deleted.
2026-10-27 16:35:57 +01:00

74 lines
3.6 KiB
PHP

<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* An error response from a cloud provider API (Hetzner, DigitalOcean, Vultr).
*
* @see https://docs.hetzner.cloud/reference/cloud#errors
*/
class CloudProviderApiException extends Exception
{
public readonly ?string $errorCode;
/** @var array<int, string> */
public readonly array $invalidFields;
/**
* The error code and field names come from the provider, so only values that look like identifiers are kept.
*
* @param array<int, mixed> $invalidFields
*/
public function __construct(
public readonly string $provider,
string $providerMessage,
public readonly int $status,
mixed $errorCode = null,
array $invalidFields = [],
) {
parent::__construct("{$provider} API error: {$providerMessage}", $status);
$errorCode = is_string($errorCode) ? strtolower($errorCode) : null;
$this->errorCode = $errorCode !== null && preg_match('/^[a-z_]{1,64}$/', $errorCode) ? $errorCode : null;
$this->invalidFields = array_values(array_filter(
$invalidFields,
fn ($name) => is_string($name) && preg_match('/^[a-z0-9_.\[\]]{1,64}$/i', $name)
));
}
/**
* A message that is safe to return to API clients. It is built only from the
* HTTP status, the error code, and field names, never from the provider's free-text message.
*/
public function safeMessage(): string
{
return match (true) {
$this->errorCode === 'resource_limit_exceeded' => "Your {$this->provider} account has reached a resource limit, for example the server limit. Remove unused resources or ask {$this->provider} for a higher limit.",
$this->errorCode === 'resource_unavailable' => 'The selected server type, image, or location is not available now. Select a different location or server type.',
$this->errorCode === 'uniqueness_error' => "A resource with the same name already exists in your {$this->provider} account, for example a server or an SSH key. Use a different name.",
$this->errorCode === 'maintenance', $this->status === 423, $this->status >= 500 => "{$this->provider} cannot process the request now. Try again later.",
$this->status === 401 => "The {$this->provider} API token is invalid or expired. Update the cloud provider token in Coolify.",
$this->status === 403 => "The {$this->provider} API token does not have permission for this request. Use a token with read and write access.",
$this->status === 404 => "{$this->provider} could not find a requested resource, for example the location, server type, image, or SSH key.",
$this->status === 409 => "{$this->provider} rejected the request because of a conflict with an existing resource.",
$this->status === 422 => "{$this->provider} rejected the request as invalid. Check the selected values and your account limits.".($this->invalidFields !== [] ? ' Invalid fields: '.implode(', ', $this->invalidFields).'.' : ''),
default => "{$this->provider} returned HTTP {$this->status}".($this->errorCode !== null ? " (error code: {$this->errorCode})" : '').'.',
};
}
/**
* The API response for a failed provider request: 502, because the upstream API rejected it.
*/
public function toJsonResponse(string $failureMessage): JsonResponse
{
return response()->json([
'message' => $failureMessage.' '.$this->safeMessage(),
'provider_status' => $this->status,
'provider_error_code' => $this->errorCode,
], 502);
}
}