Skip to content

Commit 334877f

Browse files
authored
Merge pull request #48 from github/error-responses
Consistent Error Responses
2 parents 900161a + c7fee19 commit 334877f

35 files changed

+713
-122
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ gemspec
66

77
group :development do
88
gem "irb", "~> 1"
9+
gem "ostruct", "~> 0.6.1"
910
gem "rack-test", "~> 2.2"
1011
gem "rspec", "~> 3"
1112
gem "rubocop", "~> 1"

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ GEM
9595
net-http (0.6.0)
9696
uri
9797
nio4r (2.7.4)
98+
ostruct (0.6.1)
9899
parallel (1.27.0)
99100
parser (3.3.8.0)
100101
ast (~> 2.4.1)
@@ -221,6 +222,7 @@ PLATFORMS
221222
DEPENDENCIES
222223
hooks-ruby!
223224
irb (~> 1)
225+
ostruct (~> 0.6.1)
224226
rack-test (~> 2.2)
225227
rspec (~> 3)
226228
rubocop (~> 1)

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Here is a very high-level overview of how Hooks works:
5858
```ruby
5959
# file: plugins/handlers/my_custom_handler.rb
6060
class MyCustomHandler < Hooks::Plugins::Handlers::Base
61-
def call(payload:, headers:, config:)
61+
def call(payload:, headers:, env:, config:)
6262
# Process the incoming webhook - optionally use the payload and headers
6363
# to perform some action or validation
6464
# For this example, we will just return a success message
@@ -233,7 +233,7 @@ Create custom handler plugins in the `plugins/handlers` directory to process inc
233233
```ruby
234234
# file: plugins/handlers/hello_handler.rb
235235
class HelloHandler < Hooks::Plugins::Handlers::Base
236-
def call(payload:, headers:, config:)
236+
def call(payload:, headers:, env:, config:)
237237
# Process the incoming webhook - optionally use the payload and headers
238238
# to perform some action or validation
239239
# For this example, we will just return a success message
@@ -251,7 +251,7 @@ And another handler plugin for the `/goodbye` endpoint:
251251
```ruby
252252
# file: plugins/handlers/goodbye_handler.rb
253253
class GoodbyeHandler < Hooks::Plugins::Handlers::Base
254-
def call(payload:, headers:, config:)
254+
def call(payload:, headers:, env:, config:)
255255
# Ditto for the goodbye endpoint
256256
{
257257
message: "goodbye webhook processed successfully",

docs/design.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Note: The `hooks` gem name is already taken on RubyGems, so this project is name
3333
2. **Plugin Architecture**
3434

3535
* **Team Handlers**: `class MyHandler < Hooks::Plugins::Handlers::Base`
36-
* Must implement `#call(payload:, headers:, config:)` method
36+
* Must implement `#call(payload:, headers:, env:, config:)` method
3737
* `payload`: parsed request body (JSON Hash or raw String)
3838
* `headers`: HTTP headers as Hash with string keys
3939
* `config`: merged endpoint configuration including `opts` section
@@ -230,7 +230,7 @@ endpoints_dir: ./config/endpoints # directory containing endpoint configs
230230
* **Before**: enforce `request_limit`, `request_timeout`
231231
* **Signature**: call custom or default validator
232232
* **Hooks**: run `on_request` plugins
233-
* **Handler**: invoke `MyHandler.new.call(payload:, headers:, config:)`
233+
* **Handler**: invoke `MyHandler.new.call(payload:, headers:, env:, config:)`
234234
* **After**: run `on_response` plugins
235235
* **Rescue**: on exception, run `on_error`, rethrow or format JSON error
236236

@@ -528,9 +528,10 @@ Base class for all webhook handlers.
528528
class MyHandler < Hooks::Plugins::Handlers::Base
529529
# @param payload [Hash, String] Parsed request body or raw string
530530
# @param headers [Hash<String, String>] HTTP headers
531+
# @param env [Hash] Rack environment (includes request context)
531532
# @param config [Hash] Merged endpoint configuration
532533
# @return [Hash, String, nil] Response body (auto-converted to JSON)
533-
def call(payload:, headers:, config:)
534+
def call(payload:, headers:, env:, config:)
534535
# Handler implementation
535536
{ status: "processed", id: generate_id }
536537
end

docs/handler_plugins.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ class Example < Hooks::Plugins::Handlers::Base
1717
#
1818
# @param payload [Hash, String] webhook payload (pure JSON with string keys)
1919
# @param headers [Hash] HTTP headers (string keys, optionally normalized - default is normalized)
20+
# @param env [Hash] A modifed Rack environment that contains a lot of context about the request
2021
# @param config [Hash] Endpoint configuration
2122
# @return [Hash] Response data
22-
def call(payload:, headers:, config:)
23+
def call(payload:, headers:, env:, config:)
2324
return {
2425
status: "success"
2526
}
@@ -99,6 +100,34 @@ It should be noted that the `headers` parameter is a Hash with **string keys** (
99100

100101
You can disable header normalization by either setting the environment variable `HOOKS_NORMALIZE_HEADERS` to `false` or by setting the `normalize_headers` option to `false` in the global configuration file.
101102

103+
### `env` Parameter
104+
105+
The `env` parameter is a Hash that contains a modified Rack environment. It provides a lot of context about the request, including information about the request method, path, query parameters, and more. This can be useful for debugging or for accessing additional request information. It is considered *everything plus the kitchen sink* that you might need to know about the request.
106+
107+
Here is a partial example of what the `env` parameter might look like:
108+
109+
```ruby
110+
{
111+
"REQUEST_METHOD" => "POST",
112+
"PATH_INFO" => "/webhooks/example",
113+
"QUERY_STRING" => "foo=bar&baz=123",
114+
"HTTP_VERSION" => "HTTP/1.1",
115+
"REQUEST_URI" => "https://hooks.example.com/webhooks/example?foo=bar&baz=qux",
116+
"SERVER_NAME" => "hooks.example.com",
117+
"SERVER_PORT" => 443,
118+
"CONTENT_TYPE" => "application/json",
119+
"CONTENT_LENGTH" => 123,
120+
"REMOTE_ADDR" => "<IP_ADDRESS>",
121+
"hooks.request_id" => "<REQUEST_ID>",
122+
"hooks.handler" => "ExampleHandler"
123+
"hooks.endpoint_config" => {}
124+
"hooks.start_time" => "2023-10-01T12:34:56Z",
125+
# etc...
126+
}
127+
```
128+
129+
For the complete list of available keys in the `env` parameter, you can refer to the source code at [`lib/hooks/app/rack_env_builder.rb`](../lib/hooks/app/rack_env_builder.rb).
130+
102131
### `config` Parameter
103132

104133
The `config` parameter is a Hash (symbolized) that contains the endpoint configuration. This can include any additional settings or parameters that you want to use in your handler. Most of the time, this won't be used, but sometimes endpoint configs add `opts` that can be useful for the handler.
@@ -123,9 +152,10 @@ class Example < Hooks::Plugins::Handlers::Base
123152
#
124153
# @param payload [Hash, String] Webhook payload
125154
# @param headers [Hash<String, String>] HTTP headers
155+
# @param env [Hash] A modified Rack environment that contains a lot of context about the request
126156
# @param config [Hash] Endpoint configuration
127157
# @return [Hash] Response data
128-
def call(payload:, headers:, config:)
158+
def call(payload:, headers:, env:, config:)
129159
result = Retryable.with_context(:default) do
130160
some_operation_that_might_fail()
131161
end

docs/instrument_plugins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Once configured, your custom instruments are available throughout the applicatio
160160

161161
```ruby
162162
class MyHandler < Hooks::Plugins::Handlers::Base
163-
def call(payload:, headers:, config:)
163+
def call(payload:, headers:, env:, config:)
164164
# Use your custom stats methods
165165
stats.increment("handler.calls", { handler: "MyHandler" })
166166

lib/hooks/app/api.rb

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "securerandom"
66
require_relative "helpers"
77
require_relative "auth/auth"
8+
require_relative "rack_env_builder"
89
require_relative "../plugins/handlers/base"
910
require_relative "../plugins/handlers/default"
1011
require_relative "../core/logger_factory"
@@ -65,41 +66,27 @@ def self.create(config:, endpoints:, log:)
6566
Core::LogContext.with(request_context) do
6667
begin
6768
# Build Rack environment for lifecycle hooks
68-
rack_env = {
69-
"REQUEST_METHOD" => request.request_method,
70-
"PATH_INFO" => request.path_info,
71-
"QUERY_STRING" => request.query_string,
72-
"HTTP_VERSION" => request.env["HTTP_VERSION"],
73-
"REQUEST_URI" => request.url,
74-
"SERVER_NAME" => request.env["SERVER_NAME"],
75-
"SERVER_PORT" => request.env["SERVER_PORT"],
76-
"CONTENT_TYPE" => request.content_type,
77-
"CONTENT_LENGTH" => request.content_length,
78-
"REMOTE_ADDR" => request.env["REMOTE_ADDR"],
79-
"hooks.request_id" => request_id,
80-
"hooks.handler" => handler_class_name,
81-
"hooks.endpoint_config" => endpoint_config,
82-
"hooks.start_time" => start_time.iso8601,
83-
"hooks.full_path" => full_path
84-
}
85-
86-
# Add HTTP headers to environment
87-
headers.each do |key, value|
88-
env_key = "HTTP_#{key.upcase.tr('-', '_')}"
89-
rack_env[env_key] = value
90-
end
69+
rack_env_builder = RackEnvBuilder.new(
70+
request,
71+
headers,
72+
request_context,
73+
endpoint_config,
74+
start_time,
75+
full_path
76+
)
77+
rack_env = rack_env_builder.build
9178

9279
# Call lifecycle hooks: on_request
9380
Core::PluginLoader.lifecycle_plugins.each do |plugin|
9481
plugin.on_request(rack_env)
9582
end
9683

97-
enforce_request_limits(config)
84+
enforce_request_limits(config, request_context)
9885
request.body.rewind
9986
raw_body = request.body.read
10087

10188
if endpoint_config[:auth]
102-
validate_auth!(raw_body, headers, endpoint_config, config)
89+
validate_auth!(raw_body, headers, endpoint_config, config, request_context)
10390
end
10491

10592
payload = parse_payload(raw_body, headers, symbolize: false)
@@ -109,6 +96,7 @@ def self.create(config:, endpoints:, log:)
10996
response = handler.call(
11097
payload:,
11198
headers: processed_headers,
99+
env: rack_env,
112100
config: endpoint_config
113101
)
114102

@@ -123,21 +111,32 @@ def self.create(config:, endpoints:, log:)
123111
content_type "application/json"
124112
response.to_json
125113
rescue StandardError => e
126-
# Call lifecycle hooks: on_error
114+
err_msg = "Error processing webhook event with handler: #{handler_class_name} - #{e.message} " \
115+
"- request_id: #{request_id} - path: #{full_path} - method: #{http_method} - " \
116+
"backtrace: #{e.backtrace.join("\n")}"
117+
log.error(err_msg)
118+
119+
# call lifecycle hooks: on_error if the rack_env is available
120+
# if the rack_env is not available, it means the error occurred before we could build it
127121
if defined?(rack_env)
128122
Core::PluginLoader.lifecycle_plugins.each do |plugin|
129123
plugin.on_error(e, rack_env)
130124
end
131125
end
132126

133-
log.error("an error occuring during the processing of a webhook event - #{e.message}")
127+
# construct a standardized error response
134128
error_response = {
135-
error: e.message,
136-
code: determine_error_code(e),
129+
error: "server_error",
130+
message: "an unexpected error occurred while processing the request",
137131
request_id:
138132
}
139-
error_response[:backtrace] = e.backtrace unless config[:production]
140-
status error_response[:code]
133+
134+
# enrich the error response with details if not in production
135+
error_response[:backtrace] = e.backtrace.join("\n") unless config[:production]
136+
error_response[:message] = e.message unless config[:production]
137+
error_response[:handler] = handler_class_name unless config[:production]
138+
139+
status determine_error_code(e)
141140
content_type "application/json"
142141
error_response.to_json
143142
end

lib/hooks/app/auth/auth.rb

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,45 @@ module Auth
1616
# @param headers [Hash] The request headers.
1717
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
1818
# @param global_config [Hash] The global configuration (optional, for compatibility).
19+
# @param request_context [Hash] Context for the request, e.g. request ID, path, handler (optional).
1920
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
2021
# @return [void]
2122
# @note This method will halt execution with an error if authentication fails.
22-
def validate_auth!(payload, headers, endpoint_config, global_config = {})
23+
def validate_auth!(payload, headers, endpoint_config, global_config = {}, request_context = {})
2324
auth_config = endpoint_config[:auth]
25+
request_id = request_context&.dig(:request_id)
2426

2527
# Ensure auth type is present and valid
2628
auth_type = auth_config&.dig(:type)
2729
unless auth_type&.is_a?(String) && !auth_type.strip.empty?
28-
error!("authentication configuration missing or invalid", 500)
30+
log.error("authentication configuration missing or invalid - request_id: #{request_id}")
31+
error!({
32+
error: "authentication_configuration_error",
33+
message: "authentication configuration missing or invalid",
34+
request_id:
35+
}, 500)
2936
end
3037

3138
# Get auth plugin from loaded plugins registry (boot-time loaded only)
3239
begin
3340
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
3441
rescue => e
35-
log.error("failed to load auth plugin '#{auth_type}': #{e.message}")
36-
error!("unsupported auth type '#{auth_type}'", 400)
42+
log.error("failed to load auth plugin '#{auth_type}': #{e.message} - request_id: #{request_id}")
43+
error!({
44+
error: "authentication_plugin_error",
45+
message: "unsupported auth type '#{auth_type}'",
46+
request_id:
47+
}, 400)
3748
end
3849

3950
log.debug("validating auth for request with auth_class: #{auth_class.name}")
4051
unless auth_class.valid?(payload:, headers:, config: endpoint_config)
41-
log.warn("authentication failed for request with auth_class: #{auth_class.name}")
42-
error!("authentication failed", 401)
52+
log.warn("authentication failed for request with auth_class: #{auth_class.name} - request_id: #{request_id}")
53+
error!({
54+
error: "authentication_failed",
55+
message: "authentication failed",
56+
request_id:
57+
}, 401)
4358
end
4459
end
4560

lib/hooks/app/endpoints/catchall.rb

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,36 @@ def self.route_block(captured_config, captured_logger)
2727
# :nocov:
2828
proc do
2929
request_id = uuid
30+
start_time = Time.now
3031

3132
# Use captured values
3233
config = captured_config
3334
log = captured_logger
3435

36+
full_path = "#{config[:root_path]}/#{params[:path]}"
37+
38+
handler_class_name = "DefaultHandler"
39+
http_method = "post"
40+
3541
# Set request context for logging
3642
request_context = {
3743
request_id:,
38-
path: "/#{params[:path]}",
39-
handler: "DefaultHandler"
44+
path: full_path,
45+
handler: handler_class_name
4046
}
4147

4248
Hooks::Core::LogContext.with(request_context) do
4349
begin
50+
rack_env_builder = RackEnvBuilder.new(
51+
request,
52+
headers,
53+
request_context,
54+
config,
55+
start_time,
56+
full_path
57+
)
58+
rack_env = rack_env_builder.build
59+
4460
# Enforce request limits
4561
enforce_request_limits(config)
4662

@@ -58,32 +74,34 @@ def self.route_block(captured_config, captured_logger)
5874
response = handler.call(
5975
payload:,
6076
headers:,
77+
env: rack_env,
6178
config: {}
6279
)
6380

64-
log.info "request processed successfully with default handler (id: #{request_id})"
65-
66-
# Return response as JSON string when using txt format
81+
log.info("successfully processed webhook event with handler: #{handler_class_name}")
82+
log.debug("processing duration: #{Time.now - start_time}s")
6783
status 200
6884
content_type "application/json"
69-
(response || { status: "ok" }).to_json
70-
85+
response.to_json
7186
rescue StandardError => e
72-
log.error "request failed: #{e.message} (id: #{request_id})"
87+
err_msg = "Error processing webhook event with handler: #{handler_class_name} - #{e.message} " \
88+
"- request_id: #{request_id} - path: #{full_path} - method: #{http_method} - " \
89+
"backtrace: #{e.backtrace.join("\n")}"
90+
log.error(err_msg)
7391

74-
# Return error response
92+
# construct a standardized error response
7593
error_response = {
76-
error: e.message,
77-
code: determine_error_code(e),
78-
request_id: request_id
94+
error: "server_error",
95+
message: "an unexpected error occurred while processing the request",
96+
request_id:
7997
}
8098

81-
# Add backtrace in all environments except production
82-
unless config[:production] == true
83-
error_response[:backtrace] = e.backtrace
84-
end
99+
# enrich the error response with details if not in production
100+
error_response[:backtrace] = e.backtrace.join("\n") unless config[:production]
101+
error_response[:message] = e.message unless config[:production]
102+
error_response[:handler] = handler_class_name unless config[:production]
85103

86-
status error_response[:code]
104+
status determine_error_code(e)
87105
content_type "application/json"
88106
error_response.to_json
89107
end

0 commit comments

Comments
 (0)