Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### 3.3.0 (Next)

* [#606](https://github.com/slack-ruby/slack-ruby-client/pull/606): Add configured client copies with error callbacks for Slack and transport failures - [@dblock](https://github.com/dblock).
* [#605](https://github.com/slack-ruby/slack-ruby-client/pull/605): Add configured client copies with request and response callbacks, including pagination - [@dblock](https://github.com/dblock).
* [#603](https://github.com/slack-ruby/slack-ruby-client/pull/603): Enforce consistent exception messages with rubocop-exception_messages and run lint separately from the test matrix - [@dblock](https://github.com/dblock).
* [#602](https://github.com/slack-ruby/slack-ruby-client/pull/602): Make gli an optional dependency; only the `slack` command-line client needs it - [@corsonknowles](https://github.com/corsonknowles).
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,21 @@ Request callbacks receive a `Faraday::Request` after authentication and request

Chaining registers callbacks in order. Copies preserve client configuration but use independent connections and callback lists; the original client is unchanged. Callback exceptions propagate and request callback failures prevent dispatch. Callbacks are synchronous; avoid storing sensitive authorization headers, and synchronize any shared state if using a client concurrently.

Use `with_error` to observe Slack and transport errors without recovering from them:

```ruby
observed_client = client.with_error do |error|
logger.warn(
message: error.message,
errors: error.respond_to?(:errors) && error.response ? error.errors : nil,
response_metadata: error.respond_to?(:response_metadata) && error.response ? error.response_metadata : nil
)
end
observed_client.auth_test
```

Error callbacks receive the exception and then it is re-raised unchanged. They run for every failed HTTP attempt, including failures that pagination subsequently retries. Successful calls and exceptions from request or response callbacks do not trigger them. If an error callback raises, its exception propagates instead and later callbacks are not invoked. As with other callbacks, these methods return a configured copy and can be chained.

You can control what proxy options are used by modifying the `http_proxy` environment variable per [Net::HTTP's documentation](https://docs.ruby-lang.org/en/2.0.0/Net/HTTP.html#class-Net::HTTP-label-Proxies).

Note that Docker on OSX seems to incorrectly set the proxy, causing `Faraday::ConnectionFailed, ERROR -- : Failed to open TCP connection to : (getaddrinfo: Name or service not known)`. You might need to manually unset `http_proxy` in that case, eg. `http_proxy="" bundle exec ruby ./my_bot.rb`.
Expand Down
6 changes: 6 additions & 0 deletions lib/slack/web/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def with_request(&block)
with_callback(:@request_callbacks, block)
end

def with_error(&block)
raise ArgumentError, 'error callback block is required' unless block

with_callback(:@error_callbacks, block)
end

class << self
def configure
block_given? ? yield(Config) : Config
Expand Down
13 changes: 11 additions & 2 deletions lib/slack/web/faraday/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def request(method, path, options)

def perform_request(method, path, options)
configuring_request = false
connection.send(method) do |request|
perform_http_request(method, -> { configuring_request }) do |request|
case method
when :get, :delete
request.url(path, options)
Expand All @@ -45,9 +45,18 @@ def perform_request(method, path, options)
Array(@request_callbacks).each { |callback| callback.call(request) }
configuring_request = false
end
rescue ::Faraday::ParsingError => e
rescue ::Faraday::Error => e
raise if configuring_request

Array(@error_callbacks).each { |callback| callback.call(e) }
raise
end

def perform_http_request(method, configuring_request, &block)
connection.send(method, &block)
rescue ::Faraday::ParsingError => e
raise if configuring_request.call

raise Slack::Web::Api::Errors::ParsingError, e.response
end
end
Expand Down
99 changes: 99 additions & 0 deletions spec/slack/web/client_error_callbacks_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Slack::Web::Client do
subject(:client) { described_class.new(token: 'example-token') }

it 'observes Slack errors and re-raises the same exception' do
stub_request(:post, 'https://slack.com/api/auth.test').to_return(
body: '{"ok":false,"error":"invalid_arguments","response_metadata":{"messages":["missing field"]}}'
)
errors = []
observed = client.with_error { |error| errors << error }
expect { observed.auth_test }.to(raise_error do |error|
expect(error).to equal(errors.first)
expect(error.response_metadata.messages).to eq(['missing field'])
expect(error.backtrace.first).to include('raise_error.rb')
end)
expect(errors.size).to eq(1)
end

it 'does not mutate the original client' do
stub_request(:post, 'https://slack.com/api/auth.test').to_timeout
errors = []
client.with_error { |error| errors << error }
expect { client.auth_test }.to raise_error(Slack::Web::Api::Errors::TimeoutError)
expect(errors).to be_empty
end

it 'observes transport errors' do
stub_request(:post, 'https://slack.com/api/auth.test').to_timeout
errors = []
observed = client.with_error { |error| errors << error }
expect { observed.auth_test }.to raise_error(Slack::Web::Api::Errors::TimeoutError)
expect(errors.first).to be_a(Slack::Web::Api::Errors::TimeoutError)
end

it 'observes translated parsing errors' do
stub_request(:post, 'https://slack.com/api/auth.test').to_return(body: 'not json')
errors = []
observed = client.with_error { |error| errors << error }
expect { observed.auth_test }.to(raise_error do |error|
expect(error).to be_a(Slack::Web::Api::Errors::ParsingError)
expect(error).to equal(errors.first)
expect(error.cause).to be_a(Faraday::ParsingError)
end)
end

it 'observes each pagination retry without stopping retries' do
stub_request(:post, 'https://slack.com/api/users.list')
.to_return(status: 429, headers: { 'Retry-After' => '0' }, body: '{"ok":false}')
.then.to_return(body: '{"ok":true,"members":[]}')
errors = []
responses = []
observed = client.with_error { |error| errors << error }
observed = observed.with_response { |response| responses << response }
pages = []
observed.users_list { |page| pages << page }
expect(errors.first).to be_a(Slack::Web::Api::Errors::TooManyRequestsError)
expect(errors.size).to eq(1)
expect(responses.map(&:body)).to eq(pages)
expect(pages.size).to eq(1)
end

it 'registers chained error callbacks in order' do
stub_request(:post, 'https://slack.com/api/auth.test').to_timeout
calls = []
observed = client.with_error { calls << 1 }.with_error { calls << 2 }
expect { observed.auth_test }.to raise_error(Slack::Web::Api::Errors::TimeoutError)
expect(calls).to eq([1, 2])
end

%i[with_request with_response].each do |callback_method|
it "does not observe exceptions from #{callback_method} callbacks" do
stub_request(:post, 'https://slack.com/api/auth.test').to_return(body: '{"ok":true}')
errors = []
observed = client.with_error { |error| errors << error }
callback_error = Faraday::ParsingError.new('callback error')
observed = observed.public_send(callback_method) { raise callback_error }
expect { observed.auth_test }.to(raise_error { |error| expect(error).to equal(callback_error) })
expect(errors).to be_empty
end
end

it 'propagates error callback failures without recursive notification' do
stub_request(:post, 'https://slack.com/api/auth.test').to_timeout
calls = []
observed = client.with_error do
calls << :called
raise ArgumentError, 'observer failed'
end
expect { observed.auth_test }.to raise_error(ArgumentError, 'observer failed')
expect(calls).to eq([:called])
end

it 'requires a block' do
expect { client.with_error }.to raise_error(ArgumentError, 'error callback block is required')
end
end
Loading