diff --git a/packages/http/src/HttpRequestFailed.php b/packages/http/src/HttpRequestFailed.php index b207921de..c5fd3aade 100644 --- a/packages/http/src/HttpRequestFailed.php +++ b/packages/http/src/HttpRequestFailed.php @@ -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 $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 ?: '', diff --git a/packages/router/src/Exceptions/HttpExceptionHandler.php b/packages/router/src/Exceptions/HttpExceptionHandler.php index da5562651..3d366178b 100644 --- a/packages/router/src/Exceptions/HttpExceptionHandler.php +++ b/packages/router/src/Exceptions/HttpExceptionHandler.php @@ -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; @@ -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; + } } diff --git a/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php b/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php index a20f427ea..430f15d29 100644 --- a/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php +++ b/tests/Integration/Http/Exceptions/HttpExceptionHandlerTest.php @@ -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; @@ -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 {