Skip to content

Commit f76664c

Browse files
authored
Remove generic rescues to let errors bubble up (#10)
* Refactor get_service and scale_out methods Removed unnecessary begin-rescue blocks for service retrieval and scaling. * Bump version from 0.7.0 to 0.8.0 * Refactor metric retrieval for New Relic API * Remove generic rescue for network errors Removed generic rescue block for network errors in worker.rb. * Change exception handling from RuntimeError to ApplicationError * Fix specs * Classify errors as permanent or recoverable
1 parent e7ff093 commit f76664c

15 files changed

Lines changed: 435 additions & 79 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,47 @@ The configuration file (determined by `-f FILE` command line parameter) should b
206206

207207
More details about configuration parameters can be found in [HireFire docs](https://help.hirefire.io/guides).
208208

209+
## Error handling
210+
211+
Scaltainer keeps going when a problem is likely to pass, and stops when it is not.
212+
Anything permanent, such as a mistake in your configuration or credentials that are
213+
refused, exits straight away so you hear about it immediately instead of finding it
214+
buried in the logs days later. Anything temporary is logged and tried again on the
215+
next tick.
216+
217+
### Fatal errors
218+
219+
Scaltainer exits with a non-zero status. Fix the cause and start it again.
220+
221+
| What went wrong | Where |
222+
| --- | --- |
223+
| The configuration file is missing, or is not valid YAML | startup |
224+
| `--enable-newrelic-reporting` is used without `NEW_RELIC_LICENSE_KEY` or `NEW_RELIC_APP_NAME` | startup |
225+
| `NEW_RELIC_API_KEY` is not set, or New Relic refuses it | web services |
226+
| A web service has no `newrelic_app_id`, or New Relic does not recognise the one given | web services |
227+
| A web service is missing `min_response_time` or `max_response_time`, or the two are in the wrong order | web services |
228+
| A worker service is missing `ratio` | worker services |
229+
| `endpoint` returns JSON that is not the list of queues scaltainer expects | worker services |
230+
| Kubernetes refuses the credentials, or the service account may not read or patch the resource | kubernetes |
231+
| The Docker service is global and cannot be replicated | swarm |
232+
233+
### Retriable errors
234+
235+
Scaltainer logs these and carries on. Whatever could not be handled is left alone
236+
until the next tick.
237+
238+
| What went wrong | What is skipped |
239+
| --- | --- |
240+
| A configured service is missing from the metrics response | that service |
241+
| The application is idle, so New Relic has no data for the window | that service |
242+
| `endpoint` reports a negative or non-integer queue size | that service |
243+
| The Kubernetes resource is not deployed yet, or changed while it was being scaled | that service |
244+
| Kubernetes returns a server error | that service |
245+
| No web or worker services are configured | that type |
246+
| `endpoint` answers with something that is not JSON | all worker services |
247+
| New Relic is rate limiting, erroring, or returns a payload that cannot be read | all web services |
248+
| The Prometheus push gateway is unreachable | nothing, that tick's metrics are dropped |
249+
209250
## Docker Swarm usage
210251

211252
A service definition for scaltainer is typically something like this:

lib/scaltainer/command.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ def self.parse(args)
2525
opts.on("--enable-newrelic-reporting", "Enable metrics pushing to New Relic") do
2626
newrelic_license_key = ENV['NEW_RELIC_LICENSE_KEY']
2727
newrelic_app_name = ENV['NEW_RELIC_APP_NAME']
28-
raise 'Must set NEW_RELIC_LICENSE_KEY environment variable if --enable-newrelic-reporting is set' if newrelic_license_key.nil? || newrelic_license_key == ""
29-
raise 'Must set NEW_RELIC_APP_NAME environment variable if --enable-newrelic-reporting is set' if newrelic_app_name.nil? || newrelic_app_name == ""
28+
raise ConfigurationError.new 'Must set NEW_RELIC_LICENSE_KEY environment variable if --enable-newrelic-reporting is set' if newrelic_license_key.nil? || newrelic_license_key == ""
29+
raise ConfigurationError.new 'Must set NEW_RELIC_APP_NAME environment variable if --enable-newrelic-reporting is set' if newrelic_app_name.nil? || newrelic_app_name == ""
3030
enable_newrelic_reporting = true
3131
end
3232
opts.on("-v", "--version", "Show version and exit") do

lib/scaltainer/exceptions.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
module Scaltainer
2+
# Recoverable conditions. The runner logs these and skips the affected
3+
# resource (or resource type) for the current tick, then carries on.
24
class ApplicationError < RuntimeError; end
3-
class ConfigurationError < ApplicationError; end
45
class NetworkError < ApplicationError; end
56
class Warning < ApplicationError; end
6-
end
7+
8+
# Permanent misconfiguration. Retrying cannot fix it, so it is left to bubble
9+
# up and terminate the process instead of being logged on every tick.
10+
class ConfigurationError < RuntimeError; end
11+
end

lib/scaltainer/newrelic/metrics.rb

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
module Newrelic
22
class Metrics
3+
# New Relic reports failures as an error body alongside an HTTP status.
4+
# These statuses mean the request can never succeed as configured (unknown
5+
# application id, rejected API key), so they are permanent. Anything else
6+
# (rate limiting, 5xx) is transient and worth retrying on the next tick.
7+
PERMANENT_ERROR_STATUSES = [400, 401, 403, 404].freeze
8+
39
def initialize(license_key)
410
@headers = {"X-Api-Key" => license_key}
511
@base_url = "https://api.newrelic.com/v2"
612
end
713

814
# https://docs.newrelic.com/docs/apis/rest-api-v2/application-examples-v2/average-response-time-examples-v2
15+
# Returns the average response time, or nil when New Relic holds no data for
16+
# the requested window. Callers must treat nil as "no metric for this
17+
# resource" rather than as a response time.
918
def get_avg_response_time(app_id, from, to)
1019
url = "#{@base_url}/applications/#{app_id}/metrics/data.json"
1120
conn = Excon.new(url, persistent: true, tcp_nodelay: true)
@@ -14,31 +23,83 @@ def get_avg_response_time(app_id, from, to)
1423
names[]=HttpDispatcher&values[]=average_call_time&values[]=call_count
1524
names[]=WebFrontend/QueueTime&values[]=call_count&values[]=average_response_time
1625
)
17-
response_array = request(conn, metric_names_array, time_range)
18-
http_call_count, http_average_call_time = response_array[0]["call_count"], response_array[0]["average_call_time"]
19-
webfe_call_count, webfe_average_response_time = response_array[1]["call_count"], response_array[1]["average_response_time"]
26+
http_values, webfe_values = request(conn, metric_names_array, time_range)
27+
28+
# an application that dispatched nothing in the window has no response time
29+
return nil unless http_values
30+
http_call_count = numeric_value http_values, "call_count"
31+
return nil if http_call_count.zero?
32+
http_average_call_time = numeric_value http_values, "average_call_time"
33+
34+
# queue time is an additive correction and is absent for applications that
35+
# do not sit behind a request queue, in which case it contributes nothing
36+
webfe_call_count = webfe_values ? numeric_value(webfe_values, "call_count") : 0
37+
webfe_average_response_time = webfe_values ? numeric_value(webfe_values, "average_response_time") : 0
2038

21-
http_average_call_time + (1.0 * webfe_call_count * webfe_average_response_time / http_call_count) rescue 0.0/0
39+
http_average_call_time + (1.0 * webfe_call_count * webfe_average_response_time / http_call_count)
2240
end
2341

2442
private
2543

2644
def request(conn, metric_names_array, time_range)
2745
requests = metric_names_array.map {|metric_names|
2846
{
29-
method: :get, headers: @headers,
47+
method: :get, headers: @headers,
3048
query: "#{metric_names}&#{time_range}&summarize=true"
3149
}
3250
}
3351
responses = conn.requests requests
34-
responses.map {|response|
35-
body = JSON.parse(response.body)
36-
if body["error"] && body["error"]["title"]
37-
raise body["error"]["title"]
38-
else
39-
body["metric_data"]["metrics"][0]["timeslices"][0]["values"] rescue {}
40-
end
41-
}
52+
responses.map {|response| extract_values response }
53+
end
54+
55+
# Returns the values hash of the first timeslice, or nil when New Relic
56+
# reported no data for the metric over the requested window.
57+
def extract_values(response)
58+
body = parse_body response
59+
error = body["error"]
60+
raise_api_error response.status, error["title"] if error.is_a?(Hash) && error["title"]
61+
62+
metric_data = body["metric_data"]
63+
metrics = metric_data.is_a?(Hash) ? metric_data["metrics"] : nil
64+
raise_unexpected_payload response unless metrics.is_a?(Array)
65+
return nil if metrics.empty?
66+
67+
timeslices = metrics.first.is_a?(Hash) ? metrics.first["timeslices"] : nil
68+
raise_unexpected_payload response unless timeslices.is_a?(Array)
69+
return nil if timeslices.empty?
70+
71+
values = timeslices.first.is_a?(Hash) ? timeslices.first["values"] : nil
72+
raise_unexpected_payload response unless values.is_a?(Hash)
73+
values
74+
end
75+
76+
def parse_body(response)
77+
body = JSON.parse response.body
78+
raise_unexpected_payload response unless body.is_a?(Hash)
79+
body
80+
rescue JSON::ParserError
81+
raise Scaltainer::NetworkError.new \
82+
"New Relic API returned a non json response: #{response.body[0..128]}"
83+
end
84+
85+
def numeric_value(values, key)
86+
value = values[key]
87+
unless value.is_a?(Numeric)
88+
raise Scaltainer::NetworkError.new \
89+
"New Relic API returned a non numeric #{key}: #{value.inspect}"
90+
end
91+
value
92+
end
93+
94+
def raise_api_error(status, title)
95+
message = "New Relic API error (HTTP #{status}): #{title}"
96+
raise Scaltainer::ConfigurationError.new message if PERMANENT_ERROR_STATUSES.include?(status)
97+
raise Scaltainer::NetworkError.new message
98+
end
99+
100+
def raise_unexpected_payload(response)
101+
raise Scaltainer::NetworkError.new \
102+
"New Relic API returned an unexpected payload: #{response.body[0..128]}"
42103
end
43104
end
44105
end

lib/scaltainer/orchestrators/kubernetes.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
module Scaltainer
44
class KubeResource < ReplicaSetBase
5+
# Statuses that no later attempt can recover from with the credentials at
6+
# hand, as opposed to conflicts and outages which are worth retrying.
7+
PERMANENT_ERROR_STATUSES = [401, 403].freeze
8+
59
def initialize(name, namespace)
610
@@client ||= self.class.get_client
711
type = ENV['KUBERNETES_CONTROLLER_KIND'] || 'deployment'
@@ -10,6 +14,8 @@ def initialize(name, namespace)
1014
super(name, type, namespace)
1115
@resource = @@client.send("get_#{@type}", normalize_name(@name), @namespace)
1216
@id = @resource.metadata.uid
17+
rescue Kubeclient::HttpError => e
18+
raise resource_error(e, 'find')
1319
end
1420

1521
def get_replicas
@@ -18,10 +24,25 @@ def get_replicas
1824

1925
def set_replicas(replicas)
2026
@@client.send("patch_#{@type}", normalize_name(@name), {spec: {replicas: replicas}}, @namespace)
27+
rescue Kubeclient::HttpError => e
28+
raise resource_error(e, 'scale')
2129
end
2230

2331
private
2432

33+
def resource_error(error, action)
34+
message = "Could not #{action} #{@type} #{@name} in namespace #{@namespace}: #{error.message}"
35+
case error.error_code
36+
when *PERMANENT_ERROR_STATUSES
37+
ConfigurationError.new message
38+
when 404, 409
39+
# the resource is absent or was changed since it was read, pick it up next tick
40+
Scaltainer::Warning.new message
41+
else
42+
NetworkError.new message
43+
end
44+
end
45+
2546
def self.get_client
2647
if ENV['KUBECONFIG']
2748
get_client_from_kubeconfig ENV['KUBECONFIG']

lib/scaltainer/runner.rb

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ def iterate_services(services, namespace, type, state)
6363
service_config = @default_service_config.merge service_config
6464
@logger.debug "Resource #{service_name} in namespace #{namespace} configuration: #{service_config}"
6565
process_service service_name, service_config, service_state, namespace, type, metrics
66-
rescue RuntimeError => e
66+
rescue ApplicationError => e
6767
# skipping service
6868
log_exception e
6969
end
7070
end
71-
rescue RuntimeError => e
71+
rescue ApplicationError => e
7272
# skipping service type
7373
log_exception e
7474
end
@@ -101,14 +101,10 @@ def process_service(service_name, config, state, namespace, type, metrics)
101101
end
102102

103103
def get_service(service_name, namespace)
104-
begin
105-
service = if @orchestrator == :swarm
106-
DockerService.new service_name, namespace
107-
elsif @orchestrator == :kubernetes
108-
KubeResource.new service_name, namespace
109-
end
110-
rescue => e
111-
raise NetworkError.new "Could not find resource with name #{service_name} in namespace #{namespace}: #{e.message}"
104+
service = if @orchestrator == :swarm
105+
DockerService.new service_name, namespace
106+
elsif @orchestrator == :kubernetes
107+
KubeResource.new service_name, namespace
112108
end
113109
raise ConfigurationError.new "Unknown resource: #{service_name} in namespace #{namespace}" unless service
114110
service
@@ -118,11 +114,7 @@ def scale_out(service, current_replicas, desired_replicas)
118114
return if current_replicas == desired_replicas
119115
# send scale command to orchestrator
120116
@logger.info "Scaling #{service.type} #{service.name} from #{current_replicas} to #{desired_replicas}"
121-
begin
122-
service.set_replicas desired_replicas
123-
rescue => e
124-
raise NetworkError.new "Could not scale #{service.type} #{service.name} due to error: #{e.message}"
125-
end
117+
service.set_replicas desired_replicas
126118
end
127119

128120
def register_pushgateway(pushgateway)

lib/scaltainer/service_types/web.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,7 @@ def get_metrics(services)
1616
app_id = service_config["newrelic_app_id"]
1717
raise ConfigurationError.new "Resource #{service_name} does not have a corresponding newrelic_app_id" unless app_id
1818

19-
begin
20-
metric = nr.get_avg_response_time app_id, from, to
21-
rescue => e
22-
raise NetworkError.new "Could not retrieve metrics from New Relic API for #{service_name}: #{e.message}"
23-
end
19+
metric = nr.get_avg_response_time app_id, from, to
2420

2521
hash.merge!(service_name => metric)
2622
end

lib/scaltainer/service_types/worker.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,19 @@ def get_metrics(services)
1111
m = JSON.parse(response.body)
1212
m.reduce({}){|hash, item| hash.merge!({item["name"] => (item["quantity"] || item["value"])})}
1313
rescue JSON::ParserError => e
14-
raise ConfigurationError.new "app_endpoint returned non json response: #{response.body[0..128]}"
14+
# a non json body is typically a transient upstream error page, retry next tick
15+
raise NetworkError.new "app_endpoint returned non json response: #{response.body[0..128]}"
1516
rescue TypeError => e
1617
raise ConfigurationError.new "app_endpoint returned unexpected json response: #{response.body[0..128]}"
17-
rescue => e
18-
raise NetworkError.new "Could not retrieve metrics from application endpoint: #{@app_endpoint}.\n#{e.message}"
1918
end
2019
end
2120

2221
def determine_desired_replicas(metric, service_config, current_replicas)
2322
super
2423
raise ConfigurationError.new "Missing ratio in worker resource configuration" unless service_config["ratio"]
2524
if !metric.is_a?(Integer) || metric < 0
26-
raise ConfigurationError.new "#{metric} is an invalid metric value, must be a non-negative number"
25+
# the metric comes from the endpoint at runtime, not from configuration
26+
raise Scaltainer::Warning.new "#{metric} is an invalid metric value, must be a non-negative number"
2727
end
2828
desired_replicas = (metric * 1.0 / service_config["ratio"]).ceil
2929
end

lib/scaltainer/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module Scaltainer
2-
VERSION = "0.7.0"
2+
VERSION = "0.8.0"
33
end

spec/scaltainer/exceptions_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
require 'spec_helper'
2+
3+
include Scaltainer
4+
5+
describe 'exceptions' do
6+
# The runner rescues ApplicationError to skip a resource (or resource type)
7+
# and carry on to the next tick. What is and is not an ApplicationError is
8+
# therefore the difference between being retried forever and being fatal.
9+
10+
describe 'recoverable errors' do
11+
it 'includes NetworkError' do
12+
expect(NetworkError.new).to be_a ApplicationError
13+
end
14+
15+
it 'includes Warning' do
16+
expect(Scaltainer::Warning.new).to be_a ApplicationError
17+
end
18+
end
19+
20+
describe 'permanent errors' do
21+
it 'does not include ConfigurationError' do
22+
expect(ConfigurationError.new).to_not be_a ApplicationError
23+
end
24+
25+
it 'still makes ConfigurationError rescuable as a StandardError' do
26+
expect(ConfigurationError.new).to be_a StandardError
27+
end
28+
end
29+
end

0 commit comments

Comments
 (0)