Skip to content
Draft
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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,49 @@ and associate successes, failures, errors with the protected resource.
We only require that the `semian_configuration` be **set only once** over the lifetime of
the library.

An individual `Net::HTTP` instance inherits this configuration by default. It can instead
explicitly disable Semian or replace the entire platform resource with caller-owned options:

```ruby
Net::HTTP.new("github.com", 80) # Inherit semian_configuration
Net::HTTP.new("github.com", 80, semian: true) # Also inherit semian_configuration
Net::HTTP.new("github.com", 80, semian: false) # Disable Semian for this instance
Net::HTTP.new("github.com", 80, semian: {
name: "github_api",
bulkhead: false,
success_threshold: 1,
error_threshold: 3,
error_timeout: 10,
})
```

Libraries that construct `Net::HTTP` themselves can select the same policy before the first
Semian configuration or resource lookup:

```ruby
http = Net::HTTP.new("github.com", 80)
http.semian_policy = false
# or: http.semian_policy = { name: "github_api", ... }
```

A `false` or `Hash` policy is a **whole-resource replacement**, not a merge with the
process-wide callback. In particular, a bulkhead configured by `semian_configuration` does
not protect an instance with caller-owned options unless those options also configure a
bulkhead (with `tickets` or `quota`). Likewise, `false` disables both the circuit breaker and
the bulkhead for that instance. Caller-owned options must include their own `name`.

The instance copies and freezes caller-owned options. The `name` must be a non-empty `String`
or `Symbol` of at most 255 bytes and cannot contain control characters. Per-instance hashes
are static and cannot set `dynamic: true`; use the process-wide callback described below when
options must be evaluated dynamically.

A policy may be assigned at most once before the first Semian lookup. Repeating the same policy
is allowed, but changing it after assignment or use raises
`Semian::NetHTTP::SemianPolicyChangedError`. Constructor policies are installed after normal
Ruby object initialization. A `Net::HTTP` subclass that performs a Semian lookup from its own
`initialize` method therefore cannot use the constructor override; such initializers must not
perform Semian work when per-instance policy selection is required.

If you need to return different values for the same pair of `host`/`port` value, you **must**
include the `dynamic: true` option. Returning different values for the same `host`/`port` values
without setting the `dynamic` option can lead to undesirable behavior.
Expand Down
115 changes: 105 additions & 10 deletions lib/semian/net_http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,19 @@ def initialize(msg = "Cannot re-initialize semian_configuration")
end
end

class SemianPolicyChangedError < RuntimeError
def initialize(msg = "Cannot change semian_policy after it has been set or used")
super
end
end

INHERIT_SEMIAN_POLICY = Object.new.freeze
MAX_SEMIAN_POLICY_NAME_BYTES = 255
private_constant :INHERIT_SEMIAN_POLICY, :MAX_SEMIAN_POLICY_NAME_BYTES

def semian_identifier
"nethttp_#{raw_semian_options[:name]}"
name = raw_semian_options&.fetch(:name, nil) || "#{address}_#{port}"
"nethttp_#{name}"
end

DEFAULT_ERRORS = [
Expand All @@ -47,9 +58,10 @@ def semian_identifier
].freeze # Net::HTTP can throw many different errors, this tries to capture most of them

module ClassMethods
def new(*args, semian: true)
def new(*args, semian: INHERIT_SEMIAN_POLICY)
http = super(*args)
http.instance_variable_set(:@semian_enabled, semian)
http.send(:initialize_semian_policy)
http.semian_policy = semian unless semian.equal?(INHERIT_SEMIAN_POLICY)
http
end
end
Expand Down Expand Up @@ -78,10 +90,46 @@ def reset_exceptions

Semian::NetHTTP.reset_exceptions

def semian_policy
semian_policy_mutex.synchronize { current_semian_policy }
end

def semian_policy=(policy)
validate_semian_policy!(policy)

semian_policy_mutex.synchronize do
if instance_variable_defined?(:@semian_policy)
return @semian_policy if @semian_policy == policy

raise SemianPolicyChangedError
end

if @semian_policy_used
return current_semian_policy if current_semian_policy == policy

raise SemianPolicyChangedError
end

@semian_policy = immutable_semian_policy_value(policy)
end
end

def raw_semian_options
@raw_semian_options ||= begin
@raw_semian_options = Semian::NetHTTP.retrieve_semian_configuration(address, port)
@raw_semian_options = @raw_semian_options.dup unless @raw_semian_options.nil?
semian_policy_mutex.synchronize do
@semian_policy_used = true
return @raw_semian_options if @raw_semian_options_resolved

policy = current_semian_policy
@raw_semian_options = case policy
when true
Semian::NetHTTP.retrieve_semian_configuration(address, port)&.dup
when false
nil
else
policy
end
@raw_semian_options_resolved = policy == false || !@raw_semian_options.nil?
@raw_semian_options
end
end

Expand All @@ -90,7 +138,7 @@ def resource_exceptions
end

def disabled?
raw_semian_options.nil? || @semian_enabled == false
raw_semian_options.nil?
end

def connect
Expand Down Expand Up @@ -126,6 +174,50 @@ def with_resource_timeout(timeout)

private

def initialize_semian_policy
@semian_policy_mutex ||= Mutex.new
end

def semian_policy_mutex
@semian_policy_mutex ||= Mutex.new
end

def current_semian_policy
instance_variable_defined?(:@semian_policy) ? @semian_policy : true
end

def validate_semian_policy!(policy)
unless policy == true || policy == false || policy.is_a?(Hash)
raise ArgumentError, "semian_policy must be true, false, or a Hash"
end
return unless policy.is_a?(Hash)

name = policy[:name]
unless (name.is_a?(String) || name.is_a?(Symbol)) &&
name.to_s.bytesize.between?(1, MAX_SEMIAN_POLICY_NAME_BYTES) &&
!name.to_s.match?(/[[:cntrl:]]/)
raise ArgumentError, "semian_policy :name must be a non-empty String or Symbol up to 255 bytes"
end
if policy[:dynamic]
raise ArgumentError, "semian_policy cannot be dynamic"
end
end

def immutable_semian_policy_value(value)
case value
when Hash
value.each_with_object({}) do |(key, option), copy|
copy[immutable_semian_policy_value(key)] = immutable_semian_policy_value(option)
end.freeze
when Array
value.map { |option| immutable_semian_policy_value(option) }.freeze
when String
value.dup.freeze
else
value
end
end

def handle_error_responses(result, scope:, adapter:)
if raw_semian_options.fetch(:open_circuit_server_errors, false)
semian_resource.mark_failed(result, scope: scope, adapter: adapter) if result.is_a?(::Net::HTTPServerError)
Expand All @@ -142,9 +234,12 @@ def with_cleared_dynamic_options
yield
ensure
if resource_acquisition_started
if @raw_semian_options&.fetch(:dynamic, false)
# Clear @raw_semian_options if the resource was flagged as dynamic.
@raw_semian_options = nil
semian_policy_mutex.synchronize do
if @raw_semian_options&.fetch(:dynamic, false)
# Clear @raw_semian_options if the resource was flagged as dynamic.
@raw_semian_options = nil
@raw_semian_options_resolved = false
end
end

@resource_acquisition_in_progress = false
Expand Down
Loading
Loading