From 4a3e1e069949407e4f26fc110ce047eac433c302 Mon Sep 17 00:00:00 2001 From: Tiago Lupepic Date: Tue, 17 Mar 2026 12:21:25 +0100 Subject: [PATCH 1/2] Add RetryLimitError class to handle 409 http errors --- lib/lago-ruby-client.rb | 1 + lib/lago/api/retry_limit_error.rb | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 lib/lago/api/retry_limit_error.rb diff --git a/lib/lago-ruby-client.rb b/lib/lago-ruby-client.rb index 3f92a98c..b393cf3f 100644 --- a/lib/lago-ruby-client.rb +++ b/lib/lago-ruby-client.rb @@ -11,3 +11,4 @@ require 'lago/api/client' require 'lago/api/connection' require 'lago/api/http_error' +require 'lago/api/retry_limit_error' diff --git a/lib/lago/api/retry_limit_error.rb b/lib/lago/api/retry_limit_error.rb new file mode 100644 index 00000000..5421432c --- /dev/null +++ b/lib/lago/api/retry_limit_error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Lago + module Api + class RetryLimitError < HttpError + end + end +end From ff07a6065ec7dd3814fad53a46e95a1f250fcf1f Mon Sep 17 00:00:00 2001 From: Tiago Lupepic Date: Tue, 17 Mar 2026 12:25:14 +0100 Subject: [PATCH 2/2] WIP: Handle http methods (get, put, patch, get_all) to have a auto retry This commit introduces the with_retry_limit method to auto retry when a api responds with 409 (rate limit reached). For now, the values of number of retries are arbitrary and the time to wait as well. --- lib/lago/api/connection.rb | 109 +++++++++++++++-------- spec/lago/api/connection_spec.rb | 144 ++++++++++++++++++++++++++++--- 2 files changed, 205 insertions(+), 48 deletions(-) diff --git a/lib/lago/api/connection.rb b/lib/lago/api/connection.rb index 68aa239d..f0eb61b6 100644 --- a/lib/lago/api/connection.rb +++ b/lib/lago/api/connection.rb @@ -6,6 +6,9 @@ module Lago module Api class Connection RESPONSE_SUCCESS_CODES = [200, 201, 202, 204].freeze + RETRY_LIMIT_ERROR_CODE = 429 + MAX_RETRIES = 3 + RETRY_WAIT_TIME_IN_SECONDS = 1 # FIXME: Add the correct value for the wait time between retries def initialize(api_key, uri) @api_key = api_key @@ -25,65 +28,79 @@ def post(body, path = uri.path) def put(path = uri.path, identifier:, body:) uri_path = identifier.nil? ? path : "#{path}/#{CGI.escapeURIComponent(identifier)}" - response = http_client.send_request( - 'PUT', - uri_path, - prepare_payload(body), - headers - ) - handle_response(response) + with_retry_limit do + response = http_client.send_request( + 'PUT', + uri_path, + prepare_payload(body), + headers + ) + + handle_response(response) + end end def patch(path = uri.path, identifier:, body:) uri_path = identifier.nil? ? path : "#{path}/#{CGI.escapeURIComponent(identifier)}" - response = http_client.send_request( - 'PATCH', - uri_path, - prepare_payload(body), - headers - ) - handle_response(response) + with_retry_limit do + response = http_client.send_request( + 'PATCH', + uri_path, + prepare_payload(body), + headers + ) + + handle_response(response) + end end def get(path = uri.path, identifier:) uri_path = identifier.nil? ? path : "#{path}/#{CGI.escapeURIComponent(identifier)}" - response = http_client.send_request( - 'GET', - uri_path, - prepare_payload(nil), - headers - ) - handle_response(response) + with_retry_limit do + response = http_client.send_request( + 'GET', + uri_path, + prepare_payload(nil), + headers + ) + + handle_response(response) + end end def destroy(path = uri.path, identifier:, options: nil) uri_path = path uri_path += "/#{CGI.escapeURIComponent(identifier)}" if identifier uri_path += "?#{URI.encode_www_form(options)}" unless options.nil? - response = http_client.send_request( - 'DELETE', - uri_path, - prepare_payload(nil), - headers - ) - handle_response(response) + with_retry_limit do + response = http_client.send_request( + 'DELETE', + uri_path, + prepare_payload(nil), + headers + ) + + handle_response(response) + end end def get_all(options, path = uri.path) uri_path = options.empty? ? path : "#{path}?#{URI.encode_www_form(options)}" - response = http_client.send_request( - 'GET', - uri_path, - prepare_payload(nil), - headers - ) + with_retry_limit do + response = http_client.send_request( + 'GET', + uri_path, + prepare_payload(nil), + headers + ) - handle_response(response) + handle_response(response) + end end private @@ -99,6 +116,7 @@ def headers end def handle_response(response) + raise_retry_limit_error(response) if response.code.to_i == RETRY_LIMIT_ERROR_CODE raise_error(response) unless RESPONSE_SUCCESS_CODES.include?(response.code.to_i) response.body.empty? || JSON.parse(response.body) @@ -122,6 +140,27 @@ def prepare_payload(payload) def raise_error(response) raise Lago::Api::HttpError.new(response.code.to_i, response.body, uri) end + + def raise_retry_limit_error(response) + raise Lago::Api::RetryLimitError.new(response.code.to_i, response.body, uri) + end + + def with_retry_limit + attempts = 0 + + begin + attempts += 1 + + yield + rescue Lago::Api::RetryLimitError => e + if attempts < MAX_RETRIES + sleep(RETRY_WAIT_TIME_IN_SECONDS) + retry + end + + raise e + end + end end end end diff --git a/spec/lago/api/connection_spec.rb b/spec/lago/api/connection_spec.rb index 51977bb5..23186112 100644 --- a/spec/lago/api/connection_spec.rb +++ b/spec/lago/api/connection_spec.rb @@ -12,17 +12,59 @@ let(:uri) { URI('https://testapi.example.org') } + before do + stub_const('Lago::Api::Connection::RETRY_WAIT_TIME_IN_SECONDS', 0) + end + context 'when an unsuccessful request is made' do before do stub_request(:post, 'https://testapi.example.org/NOTFOUND') - .to_return(status: 404, body: "") + .to_return(status: 404, body: '') end it 'raises an exception with an integer error code' do - expect { connection.post({}, '/NOTFOUND') }.to raise_error { |exception| - expect(exception).to be_a(Lago::Api::HttpError) - expect(exception.error_code).to eq 404 - } + expect { connection.post({}, '/NOTFOUND') }.to( + raise_error do |exception| + expect(exception).to be_a(Lago::Api::HttpError) + expect(exception.error_code).to eq 404 + end + ) + end + end + + describe '#get_all' do + let(:options) { { page: 1, per_page: 10 } } + + it do + stub = stub_request(:get, 'https://testapi.example.org/?page=1&per_page=10') + + connection.get_all(options, uri) + + expect(stub).to have_been_requested + end + + context 'when api returns 429' do + before do + stub_request(:get, 'https://testapi.example.org/?page=1&per_page=10') + .to_return(status: 429, body: '').then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.get_all(options, uri) + expect(response).to eq({ 'success' => true }) + end + end + + context 'when api returns 429 (2 times)' do + before do + stub_request(:get, 'https://testapi.example.org/?page=1&per_page=10') + .to_return(status: 429, body: '').times(2).then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.get_all(options, uri) + expect(response).to eq({ 'success' => true }) + end end end @@ -30,9 +72,34 @@ let(:identifier) { 'gid://app/Customer/12 34' } it 'encodes the identifier' do - stub_request(:get, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') - + stub = stub_request(:get, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') connection.get(identifier: identifier) + + expect(stub).to have_been_requested + end + + context 'when api returns 429' do + before do + stub_request(:get, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') + .to_return(status: 429, body: '').then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.get(identifier: identifier) + expect(response).to eq({ 'success' => true }) + end + end + + context 'when api returns 429 (2 times)' do + before do + stub_request(:get, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') + .to_return(status: 429, body: '').times(2).then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.get(identifier: identifier) + expect(response).to eq({ 'success' => true }) + end end end @@ -40,9 +107,34 @@ let(:identifier) { 'gid://app/Customer/12 34' } it 'encodes the identifier' do - stub_request(:put, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') - + stub = stub_request(:put, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') connection.put(identifier: identifier, body: nil) + + expect(stub).to have_been_requested + end + + context 'when api returns 429' do + before do + stub_request(:put, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') + .to_return(status: 429, body: nil).then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.put(identifier: identifier, body: nil) + expect(response).to eq({ 'success' => true }) + end + end + + context 'when api returns 429 (2 times)' do + before do + stub_request(:put, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') + .to_return(status: 429, body: nil).times(2).then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.put(identifier: identifier, body: nil) + expect(response).to eq({ 'success' => true }) + end end end @@ -50,9 +142,34 @@ let(:identifier) { 'gid://app/Customer/12 34' } it 'encodes the identifier' do - stub_request(:patch, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') - + stub = stub_request(:patch, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') connection.patch(identifier: identifier, body: nil) + + expect(stub).to have_been_requested + end + + context 'when api returns 429' do + before do + stub_request(:patch, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') + .to_return(status: 429, body: nil).then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.patch(identifier: identifier, body: nil) + expect(response).to eq({ 'success' => true }) + end + end + + context 'when api returns 429 (2 times)' do + before do + stub_request(:patch, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') + .to_return(status: 429, body: nil).times(2).then.to_return(status: 200, body: '{"success":true}') + end + + it 'auto retries the request' do + response = connection.patch(identifier: identifier, body: nil) + expect(response).to eq({ 'success' => true }) + end end end @@ -60,9 +177,10 @@ let(:identifier) { 'gid://app/Customer/12 34' } it 'encodes the identifier' do - stub_request(:delete, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') - + stub = stub_request(:delete, 'https://testapi.example.org:443/gid:%2F%2Fapp%2FCustomer%2F12%2034') connection.destroy(identifier: identifier) + + expect(stub).to have_been_requested end end end