Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/http/src/HttpRequestFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ final class HttpRequestFailed extends Exception implements ProvidesContext
* @param string|null $message An optional message that will be displayed to the client.
* @param Response|null $cause The response that caused the failure, if any.
* @param Request|null $request The request that failed, for debug purposes.
* @param array<string, string|string[]> $headers Headers that the response must carry, such as `retry-after` on a `429`.
*/
public function __construct(
private(set) readonly Status $status,
?string $message = null,
private(set) readonly ?Response $cause = null,
private(set) readonly ?Request $request = null,
private(set) readonly array $headers = [],
) {
parent::__construct(
message: $message ?: '',
Expand Down
26 changes: 25 additions & 1 deletion packages/router/src/Exceptions/HttpExceptionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Tempest\Core\Exceptions\ExceptionProcessor;
use Tempest\Core\Kernel;
use Tempest\Http\GenericResponse;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Request;
use Tempest\Http\Response;
use Tempest\Http\Status;
Expand Down Expand Up @@ -46,10 +47,33 @@ public function renderResponse(Request $request, Throwable $throwable): Response
$renderer = $this->container->get($rendererClass);

if ($renderer->canRender($throwable, $request)) {
return $renderer->render($throwable);
return $this->applyHeaders($renderer->render($throwable), $throwable);
}
}

return new GenericResponse(status: Status::NOT_ACCEPTABLE);
}

/**
* Applies the headers declared by a failure, which renderers would otherwise discard by building a response from scratch.
*/
private function applyHeaders(Response $response, Throwable $throwable): Response
{
if (! $throwable instanceof HttpRequestFailed) {
return $response;
}

foreach ($throwable->headers as $name => $values) {
// Headers keep the casing they were added with, so we look up the existing one to avoid a duplicate
$existing = $response->getHeader($name);

$response->removeHeader($existing === null ? $name : $existing->name);

foreach (is_array($values) ? $values : [$values] as $value) {
$response->addHeader($name, $value);
}
}

return $response;
}
}
48 changes: 48 additions & 0 deletions tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Tempest\Core\Kernel;
use Tempest\Http\HttpRequestFailed;
use Tempest\Http\Response;
use Tempest\Http\Responses\Json;
use Tempest\Http\Responses\Redirect;
use Tempest\Http\Status;
use Tempest\Router\Exceptions\HttpExceptionHandler;
Expand Down Expand Up @@ -132,6 +133,53 @@ public function exception_handler_returns_same_code_as_http_exception(Status $st
$this->assertSame($status, $this->response->status);
}

#[Test]
public function exception_handler_applies_headers_declared_on_the_exception(): void
{
$this->callExceptionHandler(function (): void {
$handler = $this->container->get(HttpExceptionHandler::class);
$handler->handle(new HttpRequestFailed(
status: Status::UNAUTHORIZED,
headers: ['www-authenticate' => 'Bearer'],
));
});

$this->assertContains('Bearer', $this->response->getHeader('www-authenticate')->values);
}

#[Test]
public function exception_handler_replaces_headers_that_the_rendered_response_already_carries(): void
{
// The renderer forwards a cause that has a body, so the response it returns already carries the header
$cause = new Json(body: ['message' => 'Slow down.'], status: Status::TOO_MANY_REQUESTS)
->addHeader('Retry-After', '30');

$this->callExceptionHandler(function () use ($cause): void {
$handler = $this->container->get(HttpExceptionHandler::class);
$handler->handle(new HttpRequestFailed(
status: Status::TOO_MANY_REQUESTS,
cause: $cause,
headers: ['retry-after' => '60'],
));
});

$this->assertSame(['60'], $this->response->getHeader('retry-after')->values);
}

#[Test]
public function exception_handler_applies_every_value_of_a_declared_header(): void
{
$this->callExceptionHandler(function (): void {
$handler = $this->container->get(HttpExceptionHandler::class);
$handler->handle(new HttpRequestFailed(
status: Status::METHOD_NOT_ALLOWED,
headers: ['allow' => ['GET', 'HEAD']],
));
});

$this->assertSame(['GET', 'HEAD'], $this->response->getHeader('allow')->values);
}

#[Test]
public function exception_handler_runs_exception_processors(): void
{
Expand Down
Loading