File Coverage

lib/PAGI/Middleware/ErrorHandler.pm
Criterion Covered Total %
statement 78 82 95.1
branch 25 28 89.2
condition 12 19 63.1
subroutine 11 11 100.0
pod 1 1 100.0
total 127 141 90.0


line stmt bran cond sub pod time code
1             package PAGI::Middleware::ErrorHandler;
2              
3 1     1   200769 use strict;
  1         3  
  1         32  
4 1     1   3 use warnings;
  1         3  
  1         48  
5 1     1   352 use parent 'PAGI::Middleware';
  1         260  
  1         5  
6 1     1   56 use Future::AsyncAwait;
  1         1  
  1         4  
7 1     1   41 use Scalar::Util 'blessed';
  1         1  
  1         1302  
8              
9             =head1 NAME
10              
11             PAGI::Middleware::ErrorHandler - Exception handling middleware
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Middleware::Builder;
16              
17             my $app = builder {
18             enable 'ErrorHandler',
19             development => 1,
20             on_error => sub {
21             my ($error) = @_; warn "App error: $error" };
22             $my_app;
23             };
24              
25             =head1 DESCRIPTION
26              
27             PAGI::Middleware::ErrorHandler catches exceptions thrown by the inner
28             application and converts them to appropriate HTTP error responses.
29              
30             =head1 CONFIGURATION
31              
32             =over 4
33              
34             =item * development (default: 0)
35              
36             If true, include stack trace in error responses. Should be false in production.
37              
38             =item * on_error (default: undef)
39              
40             Callback invoked with the error when an exception is caught. Useful for logging.
41              
42             on_error => sub {
43             my ($error) = @_; $logger->error($error) }
44              
45             =item * content_type (default: 'text/html')
46              
47             Content type for error responses. Supported: 'text/html', 'application/json', 'text/plain'
48              
49             =item * status (default: 500)
50              
51             HTTP status code for general exceptions.
52              
53             =back
54              
55             =cut
56              
57             sub _init {
58 10     10   24 my ($self, $config) = @_;
59              
60 10   100     67 $self->{development} = $config->{development} // 0;
61 10         28 $self->{on_error} = $config->{on_error};
62 10   100     58 $self->{content_type} = $config->{content_type} // 'text/html';
63 10   50     50 $self->{status} = $config->{status} // 500;
64             }
65              
66             sub wrap {
67 10     10 1 104 my ($self, $app) = @_;
68              
69 10     10   205 return async sub {
70 10         22 my ($scope, $receive, $send) = @_;
71             # Only handle HTTP requests
72 10 100       39 if ($scope->{type} ne 'http') {
73 1         4 await $app->($scope, $receive, $send);
74 0         0 return;
75             }
76              
77 9         22 my $response_started = 0;
78              
79             # Intercept send to track if response has started
80 2         152 my $wrapped_send = async sub {
81 2         4 my ($event) = @_;
82 2 100       8 if ($event->{type} eq 'http.response.start') {
83 1         2 $response_started = 1;
84             }
85 2         7 await $send->($event);
86 9         37 };
87              
88             # Try to run the app
89 9         18 my $error;
90             eval {
91 9         30 await $app->($scope, $receive, $wrapped_send);
92 1         120 1;
93 9 100       20 } or do {
94 8   50     790 $error = $@ || 'Unknown error';
95             };
96              
97             # Handle error if one occurred
98 9 100       33 if ($error) {
99             # Call on_error callback if provided
100 8 100       22 if ($self->{on_error}) {
101 1         3 eval { $self->{on_error}->($error) };
  1         5  
102             }
103              
104             # If response already started, we can't send error page
105 8 50       28 if ($response_started) {
106             # Best we can do is log and close
107 0         0 warn "Error occurred after response started: $error\n";
108 0         0 return;
109             }
110              
111             # Determine status code
112 8         17 my $status = $self->{status};
113              
114             # Check for specific exception types
115 8 100 66     36 if (blessed($error) && $error->can('status_code')) {
116 1         4 $status = $error->status_code;
117             }
118              
119             # Generate error response
120 8         28 my ($body, $content_type) = $self->_generate_error_body($error, $status);
121              
122 8         62 await $send->({
123             type => 'http.response.start',
124             status => $status,
125             headers => [
126             ['content-type', $content_type],
127             ['content-length', length($body)],
128             ],
129             });
130              
131 8         484 await $send->({
132             type => 'http.response.body',
133             body => $body,
134             more => 0,
135             });
136             }
137 10         63 };
138             }
139              
140             sub _generate_error_body {
141 8     8   17 my ($self, $error, $status) = @_;
142              
143 8         20 my $error_text = "$error";
144 8         18 my $content_type = $self->{content_type};
145              
146             # Clean up error for display
147 8         11 my $display_error = $error_text;
148 8 100       20 unless ($self->{development}) {
149             # In production, don't reveal internal details
150 5         15 $display_error = $self->_status_message($status);
151             }
152              
153 8 100       28 if ($content_type eq 'application/json') {
    100          
154 1         581 require JSON::MaybeXS;
155             my $body = JSON::MaybeXS::encode_json({
156             error => $display_error,
157             status => $status,
158 1 50       8183 ($self->{development} ? (stack => $error_text) : ()),
159             });
160 1         6 return ($body, 'application/json');
161             }
162             elsif ($content_type eq 'text/plain') {
163 1         4 my $body = "Error $status: $display_error";
164 1 50 33     23 if ($self->{development} && $error_text ne $display_error) {
165 0         0 $body .= "\n\nStack trace:\n$error_text";
166             }
167 1         5 return ($body, 'text/plain; charset=utf-8');
168             }
169             else {
170             # Default to HTML
171 6         15 my $safe_error = $self->_html_escape($display_error);
172 6 100       18 my $safe_stack = $self->{development} ? $self->_html_escape($error_text) : '';
173              
174 6         20 my $body = <<"HTML";
175            
176            
177            
178             Error $status
179            
185            
186            
187            

Error $status

188            
$safe_error
189             HTML
190              
191 6 100 66     25 if ($self->{development} && $safe_stack) {
192 2         7 $body .= "

Stack Trace

\n
$safe_stack
\n";
193             }
194              
195 6         10 $body .= "\n\n";
196              
197 6         25 return ($body, 'text/html; charset=utf-8');
198             }
199             }
200              
201             sub _html_escape {
202 8     8   19 my ($self, $text) = @_;
203              
204 8         18 $text =~ s/&/&/g;
205 8         19 $text =~ s/
206 8         17 $text =~ s/>/>/g;
207 8         14 $text =~ s/"/"/g;
208 8         18 return $text;
209             }
210              
211             sub _status_message {
212 5     5   11 my ($self, $status) = @_;
213              
214 5         55 my %messages = (
215             400 => 'Bad Request',
216             401 => 'Unauthorized',
217             403 => 'Forbidden',
218             404 => 'Not Found',
219             405 => 'Method Not Allowed',
220             408 => 'Request Timeout',
221             413 => 'Payload Too Large',
222             429 => 'Too Many Requests',
223             500 => 'Internal Server Error',
224             502 => 'Bad Gateway',
225             503 => 'Service Unavailable',
226             504 => 'Gateway Timeout',
227             );
228 5   50     34 return $messages{$status} // 'Error';
229             }
230              
231             1;
232              
233             __END__