-
Type
-
print($type); ?>
+
+
+
+
print($label); ?>
+
print($message); ?>
+
+
+ print($type); ?>
-
-
Error Trace
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+ $traceItem): ?>
+
+
+
file
+
print($traceItem['file']); ?>
+
+
+
+
line
+
print($traceItem['line']); ?>
+
+
+
+
function
+
print($traceItem['function']); ?>
+
+
+
+
args
+
print(\var_export($traceItem['args'], true)); ?>
+
+
+
+
+
+
+
+
Powered by
+
+
+
-
\ No newline at end of file
diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php
index 338da29403..f8f7b9ac6c 100644
--- a/src/Appwrite/Extend/Exception.php
+++ b/src/Appwrite/Extend/Exception.php
@@ -165,6 +165,7 @@ class Exception extends \Exception
public const FUNCTION_SYNCHRONOUS_TIMEOUT = 'function_synchronous_timeout';
public const FUNCTION_TEMPLATE_NOT_FOUND = 'function_template_not_found';
public const FUNCTION_RUNTIME_NOT_DETECTED = 'function_runtime_not_detected';
+ public const FUNCTION_EXECUTE_PERMISSION_MISSING = 'function_execute_permission_missing';
/** Deployments */
public const DEPLOYMENT_NOT_FOUND = 'deployment_not_found';
@@ -174,6 +175,7 @@ class Exception extends \Exception
public const BUILD_NOT_READY = 'build_not_ready';
public const BUILD_IN_PROGRESS = 'build_in_progress';
public const BUILD_ALREADY_COMPLETED = 'build_already_completed';
+ public const BUILD_CANCELED = 'build_canceled';
public const BUILD_FAILED = 'build_failed';
/** Execution */
@@ -320,11 +322,14 @@ class Exception extends \Exception
protected string $type = '';
protected array $errors = [];
protected bool $publish;
+ private array $ctas = [];
+ private ?string $view = null;
- public function __construct(string $type = Exception::GENERAL_UNKNOWN, string $message = null, int|string $code = null, \Throwable $previous = null)
+ public function __construct(string $type = Exception::GENERAL_UNKNOWN, string $message = null, int|string $code = null, \Throwable $previous = null, ?string $view = null)
{
$this->errors = Config::getParam('errors');
$this->type = $type;
+ $this->view = $view;
$this->code = $code ?? $this->errors[$type]['code'];
// Mark string errors like HY001 from PDO as 500 errors
@@ -374,4 +379,23 @@ class Exception extends \Exception
{
return $this->publish;
}
+
+ public function addCTA(string $label, ?string $url = null): self
+ {
+ $this->ctas[] = [
+ 'label' => $label,
+ 'url' => $url
+ ];
+ return $this;
+ }
+
+ public function getCTAs(): array
+ {
+ return $this->ctas;
+ }
+
+ public function getView(): ?string
+ {
+ return $this->view;
+ }
}
diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
index bee774be08..f2de2c7737 100644
--- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
+++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php
@@ -2177,4 +2177,180 @@ class FunctionsCustomServerTest extends Scope
$this->cleanupFunction($functionId);
}
+
+ public function testErrorPages(): void
+ {
+ // non-existent domain
+ $domain = 'non-existent-page.functions.localhost';
+
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://' . $domain);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/');
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString('Nothing is here yet', $response['body']);
+ $this->assertStringContainsString('Start with this domain', $response['body']);
+
+ // failed deployment
+ $functionId = $this->setupFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Error Pages',
+ 'runtime' => 'php-8.0',
+ 'entrypoint' => 'index.php',
+ 'timeout' => 15,
+ 'commands' => 'cd non-existing-directory',
+ 'execute' => ['any']
+ ]);
+
+ $domain = $this->setupFunctionDomain($functionId);
+ $proxyClient->setEndpoint('http://' . $domain);
+
+ $deployment = $this->createDeployment($functionId, [
+ 'entrypoint' => 'index.php',
+ 'code' => $this->packageFunction('php'),
+ 'activate' => true
+ ]);
+
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString('No active deployments', $response['body']);
+ $this->assertStringContainsString('View deployments', $response['body']);
+
+ // canceled deployment
+ $deployment = $this->createDeployment($functionId, [
+ 'entrypoint' => 'index.php',
+ 'code' => $this->packageFunction('php'),
+ 'activate' => true
+ ]);
+
+ $deploymentId = $deployment['body']['$id'] ?? '';
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+
+ $deployment = $this->cancelDeployment($functionId, $deploymentId);
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('canceled', $deployment['body']['status']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString('No active deployments', $response['body']);
+ $this->assertStringContainsString('View deployments', $response['body']);
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testErrorPagesPermissions(): void
+ {
+ $functionId = $this->setupFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Error Pages',
+ 'runtime' => 'php-8.0',
+ 'entrypoint' => 'index.php',
+ 'timeout' => 15,
+ 'commands' => '',
+ 'execute' => ['users']
+ ]);
+
+ $domain = $this->setupFunctionDomain($functionId);
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://' . $domain);
+
+ $deploymentId = $this->setupDeployment($functionId, [
+ 'code' => $this->packageFunction('php'),
+ 'activate' => true
+ ]);
+ $this->assertNotEmpty($deploymentId);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+
+ $this->assertEquals(401, $response['headers']['status-code']);
+ $this->assertStringContainsString('Execution not permitted', $response['body']);
+ $this->assertStringContainsString('View settings', $response['body']);
+
+ $this->cleanupFunction($functionId);
+ }
+
+ public function testErrorPagesEmptyBody(): void
+ {
+ $functionId = $this->setupFunction([
+ 'functionId' => ID::unique(),
+ 'name' => 'Test Error Pages',
+ 'runtime' => 'php-8.0',
+ 'entrypoint' => 'index.php',
+ 'timeout' => 15,
+ 'commands' => '',
+ 'execute' => ['any']
+ ]);
+
+ $domain = $this->setupFunctionDomain($functionId);
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://' . $domain);
+
+ $deploymentId = $this->setupDeployment($functionId, [
+ 'code' => $this->packageFunction('php'),
+ 'activate' => true
+ ]);
+ $this->assertNotEmpty($deploymentId);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=404', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString('Error 404', $response['body']);
+ $this->assertStringContainsString('does not exist', $response['body']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=504', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+ $this->assertEquals(504, $response['headers']['status-code']);
+ $this->assertStringContainsString('Error 504', $response['body']);
+ $this->assertStringContainsString('respond in time', $response['body']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=400', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+ $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertStringContainsString('Error 400', $response['body']);
+ $this->assertStringContainsString('unexpected client error', $response['body']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=500', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+ $this->assertEquals(500, $response['headers']['status-code']);
+ $this->assertStringContainsString('Error 500', $response['body']);
+ $this->assertStringContainsString('unexpected server error', $response['body']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=400&body=CustomError400', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+ $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertStringContainsString('CustomError400', $response['body']);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/custom-response?code=500&body=CustomError500', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ]));
+ $this->assertEquals(500, $response['headers']['status-code']);
+ $this->assertStringContainsString('CustomError500', $response['body']);
+
+ $this->cleanupFunction($functionId);
+ }
}
diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php
index ff7e4b283f..00edcc1b72 100644
--- a/tests/e2e/Services/Sites/SitesBase.php
+++ b/tests/e2e/Services/Sites/SitesBase.php
@@ -49,7 +49,7 @@ trait SitesBase
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals('ready', $deployment['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT));
- }, 100000, 500);
+ }, 150000, 500);
// Not === so multipart/form-data works fine too
if (($params['activate'] ?? false) == true) {
diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php
index 0b3b1710c1..e5f88461b2 100644
--- a/tests/e2e/Services/Sites/SitesCustomServerTest.php
+++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php
@@ -1655,8 +1655,8 @@ class SitesCustomServerTest extends Scope
$response = $proxyClient->call(Client::METHOD_GET, '/');
- $this->assertEquals(401, $response['headers']['status-code']);
- $this->assertStringContainsString("This domain is not connected to any Appwrite resource yet", $response['body']);
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString("This page is empty, but you can make it yours.", $response['body']);
$site = $this->createSite([
'siteId' => ID::unique(),
@@ -1707,8 +1707,8 @@ class SitesCustomServerTest extends Scope
$siteDomain = $this->setupSiteDomain($siteId);
$this->assertNotEmpty($siteDomain);
- $delpoymentDomain = $this->getDeploymentDomain($deploymentId);
- $this->assertNotEmpty($delpoymentDomain);
+ $deploymentDomain = $this->getDeploymentDomain($deploymentId);
+ $this->assertNotEmpty($deploymentDomain);
$proxyClient = new Client();
$proxyClient->setEndpoint('http://' . $siteDomain);
@@ -1719,7 +1719,7 @@ class SitesCustomServerTest extends Scope
$contentLength = $response['headers']['content-length'];
$proxyClient = new Client();
- $proxyClient->setEndpoint('http://' . $delpoymentDomain);
+ $proxyClient->setEndpoint('http://' . $deploymentDomain);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
$this->assertEquals(301, $response['headers']['status-code']);
$this->assertStringContainsString('/console/auth/preview', $response['headers']['location']);
@@ -2435,7 +2435,7 @@ class SitesCustomServerTest extends Scope
}, 100000, 500);
$response = $proxyClient->call(Client::METHOD_GET, '/');
- $this->assertStringContainsString('build_failed', $response['body']);
+ $this->assertStringContainsString('This page is empty, activate a deployment to make it live.', $response['body']);
$this->cleanupSite($siteId);
}
@@ -2505,6 +2505,113 @@ class SitesCustomServerTest extends Scope
$this->cleanupSite($siteId);
}
+ public function testErrorPages(): void
+ {
+ // non-existent domain page
+ $domain = 'non-existent-page.sites.localhost';
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://' . $domain);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/');
+
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString('Nothing is here yet', $response['body']);
+ $this->assertStringContainsString('Start with this domain', $response['body']);
+
+ $siteId = $this->setupSite([
+ 'siteId' => ID::unique(),
+ 'name' => 'Static site',
+ 'framework' => 'other',
+ 'buildRuntime' => 'node-22',
+ 'outputDirectory' => './',
+ 'buildCommand' => 'sleep 5 && cd non-existing-directory',
+ ]);
+ $this->assertNotEmpty($siteId);
+
+ $domain = $this->setupSiteDomain($siteId);
+
+ // test canceled deployment error page
+ $deployment = $this->createDeployment($siteId, [
+ 'code' => $this->packageSite('static'),
+ 'activate' => 'true'
+ ]);
+ $deploymentId = $deployment['body']['$id'] ?? '';
+ $this->assertEquals(202, $deployment['headers']['status-code']);
+ $this->assertNotEmpty($deployment['body']['$id']);
+
+ $deployment = $this->cancelDeployment($siteId, $deploymentId);
+ $this->assertEquals(200, $deployment['headers']['status-code']);
+ $this->assertEquals('canceled', $deployment['body']['status']);
+
+ $deploymentDomain = $this->getDeploymentDomain($deploymentId);
+ $this->assertNotEmpty($deploymentDomain);
+
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://' . $deploymentDomain);
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
+ $this->assertEquals(301, $response['headers']['status-code']);
+
+ $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 0);
+ $apiKey = $jwtObj->encode([
+ 'projectCheckDisabled' => true,
+ 'previewAuthDisabled' => true,
+ ]);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
+ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ ]);
+ $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertStringContainsString("Deployment build canceled", $response['body']);
+ $this->assertStringContainsString("View deployments", $response['body']);
+
+ // check site domain for no active deployments
+ $proxyClient->setEndpoint('http://' . $domain);
+ $response = $proxyClient->call(Client::METHOD_GET, '/');
+ $this->assertEquals(404, $response['headers']['status-code']);
+ $this->assertStringContainsString('No active deployments', $response['body']);
+ $this->assertStringContainsString('View deployments', $response['body']);
+
+ $deployment = $this->createDeployment($siteId, [
+ 'code' => $this->packageSite('astro'),
+ 'activate' => 'true'
+ ]);
+
+ $deploymentId = $deployment['body']['$id'] ?? '';
+ $this->assertNotEmpty($deploymentId);
+
+ $deploymentDomain = $this->getDeploymentDomain($deploymentId);
+ $this->assertNotEmpty($deploymentDomain);
+
+ $proxyClient->setEndpoint('http://' . $deploymentDomain);
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false);
+ $this->assertEquals(301, $response['headers']['status-code']);
+
+ // deployment is still building error page
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
+ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ ]);
+ $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertStringContainsString("Deployment is still building", $response['body']);
+ $this->assertStringContainsString("View logs", $response['body']);
+ $this->assertStringContainsString("Reload", $response['body']);
+
+ $this->assertEventually(function () use ($siteId, $deploymentId) {
+ $deployment = $this->getDeployment($siteId, $deploymentId);
+
+ $this->assertEquals('failed', $deployment['body']['status']);
+ }, 50000, 500);
+
+ // deployment failed error page
+ $response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
+ 'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
+ ]);
+ $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertStringContainsString("Deployment build failed", $response['body']);
+ $this->assertStringContainsString("View logs", $response['body']);
+
+ $this->cleanupSite($siteId);
+ }
+
public function testEmptySiteSource(): void
{
$siteId = $this->setupSite([
diff --git a/tests/resources/functions/php/index.php b/tests/resources/functions/php/index.php
index 27a9418b3c..6c67f27ee1 100644
--- a/tests/resources/functions/php/index.php
+++ b/tests/resources/functions/php/index.php
@@ -1,6 +1,12 @@
req->path === '/custom-response') {
+ $code = (int) ($context->req->query['code'] ?? '200');
+ $body = $context->req->query['body'] ?? '';
+ return $context->res->send($body, $code);
+ }
+
$context->log('body-is-' . ($context->req->body ?? ''));
$context->log('custom-header-is-' . ($context->req->headers['x-custom-header'] ?? ''));
$context->log('method-is-' . \strtolower($context->req->method ?? ''));