|
20 | 20 | require 'optimizely/cmab/cmab_client'
|
21 | 21 |
|
22 | 22 | describe Optimizely::DefaultCmabClient do
|
23 |
| - let(:mock_http_client) { double('http_client') } |
24 | 23 | let(:spy_logger) { spy('logger') }
|
25 |
| - let(:retry_config) { Optimizely::CmabRetryConfig.new(max_retries: 3, retry_delay: 0.01, max_backoff: 1, backoff_multiplier: 2) } |
26 | 24 | let(:rule_id) { 'test_rule' }
|
27 | 25 | let(:user_id) { 'user123' }
|
28 | 26 | let(:attributes) { {'attr1': 'value1', 'attr2': 'value2'} }
|
|
49 | 47 |
|
50 | 48 | after do
|
51 | 49 | RSpec::Mocks.space.proxy_for(spy_logger).reset
|
52 |
| - RSpec::Mocks.space.proxy_for(mock_http_client).reset |
53 | 50 | end
|
54 | 51 |
|
55 |
| - it 'should return the variation id on success without retrying' do |
56 |
| - client = described_class.new(mock_http_client, nil, spy_logger) |
57 |
| - mock_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'abc123'}]}) |
58 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
59 |
| - |
60 |
| - result = client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
61 |
| - |
62 |
| - expect(result).to eq('abc123') |
63 |
| - expect(mock_http_client).to have_received(:post).with( |
64 |
| - expected_url, |
65 |
| - hash_including( |
66 |
| - json: expected_body, |
67 |
| - headers: expected_headers, |
68 |
| - timeout: 10 |
69 |
| - ) |
70 |
| - ).once |
71 |
| - expect(Kernel).not_to have_received(:sleep) |
| 52 | + context 'when client is configured without retries' do |
| 53 | + let(:mock_http_client) { double('http_client') } |
| 54 | + let(:client) { described_class.new(mock_http_client, nil, spy_logger) } |
| 55 | + |
| 56 | + it 'should return the variation id on success' do |
| 57 | + mock_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'abc123'}]}) |
| 58 | + allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 59 | + |
| 60 | + result = client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
| 61 | + |
| 62 | + expect(result).to eq('abc123') |
| 63 | + expect(mock_http_client).to have_received(:post).with( |
| 64 | + expected_url, |
| 65 | + hash_including( |
| 66 | + json: expected_body, |
| 67 | + headers: expected_headers, |
| 68 | + timeout: 10 |
| 69 | + ) |
| 70 | + ).once |
| 71 | + expect(Kernel).not_to have_received(:sleep) |
| 72 | + end |
| 73 | + |
| 74 | + it 'should return HTTP exception' do |
| 75 | + allow(mock_http_client).to receive(:post).and_raise(StandardError.new('Connection error')) |
| 76 | + |
| 77 | + expect do |
| 78 | + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
| 79 | + end.to raise_error(Optimizely::CmabFetchError, /Connection error/) |
| 80 | + |
| 81 | + expect(mock_http_client).to have_received(:post).once |
| 82 | + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Connection error')) |
| 83 | + expect(Kernel).not_to have_received(:sleep) |
| 84 | + end |
| 85 | + |
| 86 | + it 'should not return 200 status' do |
| 87 | + mock_response = double('response', status_code: 500, json: nil) |
| 88 | + allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 89 | + |
| 90 | + expect do |
| 91 | + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
| 92 | + end.to raise_error(Optimizely::CmabFetchError, /500/) |
| 93 | + |
| 94 | + expect(mock_http_client).to have_received(:post).with( |
| 95 | + expected_url, |
| 96 | + hash_including( |
| 97 | + json: expected_body, |
| 98 | + headers: expected_headers, |
| 99 | + timeout: 10 |
| 100 | + ) |
| 101 | + ).once |
| 102 | + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('500')) |
| 103 | + expect(Kernel).not_to have_received(:sleep) |
| 104 | + end |
| 105 | + |
| 106 | + it 'should return invalid json' do |
| 107 | + mock_response = double('response', status_code: 200) |
| 108 | + allow(mock_response).to receive(:json).and_raise(JSON::ParserError.new('Expecting value')) |
| 109 | + allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 110 | + |
| 111 | + expect do |
| 112 | + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
| 113 | + end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/) |
| 114 | + |
| 115 | + expect(mock_http_client).to have_received(:post).with( |
| 116 | + expected_url, |
| 117 | + hash_including( |
| 118 | + json: expected_body, |
| 119 | + headers: expected_headers, |
| 120 | + timeout: 10 |
| 121 | + ) |
| 122 | + ).once |
| 123 | + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response')) |
| 124 | + expect(Kernel).not_to have_received(:sleep) |
| 125 | + end |
| 126 | + |
| 127 | + it 'should return invalid response structure' do |
| 128 | + mock_response = double('response', status_code: 200, json: {'no_predictions' => []}) |
| 129 | + allow(mock_http_client).to receive(:post).and_return(mock_response) |
| 130 | + |
| 131 | + expect do |
| 132 | + client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
| 133 | + end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/) |
| 134 | + |
| 135 | + expect(mock_http_client).to have_received(:post).with( |
| 136 | + expected_url, |
| 137 | + hash_including( |
| 138 | + json: expected_body, |
| 139 | + headers: expected_headers, |
| 140 | + timeout: 10 |
| 141 | + ) |
| 142 | + ).once |
| 143 | + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response')) |
| 144 | + expect(Kernel).not_to have_received(:sleep) |
| 145 | + end |
72 | 146 | end
|
73 | 147 |
|
74 |
| - it 'should return HTTP exception without retrying' do |
75 |
| - client = described_class.new(mock_http_client, nil, spy_logger) |
76 |
| - allow(mock_http_client).to receive(:post).and_raise(StandardError.new('Connection error')) |
| 148 | + context 'when client is configured with retries' do |
| 149 | + let(:mock_http_client) { double('http_client') } # Fresh double for this context |
| 150 | + let(:retry_config) { Optimizely::CmabRetryConfig.new(max_retries: 3, retry_delay: 0.01, max_backoff: 1, backoff_multiplier: 2) } |
| 151 | + let(:client_with_retry) { described_class.new(mock_http_client, retry_config, spy_logger) } |
77 | 152 |
|
78 |
| - expect do |
79 |
| - client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
80 |
| - end.to raise_error(Optimizely::CmabFetchError, /Connection error/) |
| 153 | + it 'should return the variation id on first try with retry config but no retry needed' do |
| 154 | + mock_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'abc123'}]}) |
| 155 | + allow(mock_http_client).to receive(:post).and_return(mock_response) |
81 | 156 |
|
82 |
| - expect(mock_http_client).to have_received(:post).once |
83 |
| - expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Connection error')) |
84 |
| - expect(Kernel).not_to have_received(:sleep) |
85 |
| - end |
86 |
| - |
87 |
| - it 'should not return 200 status without retrying' do |
88 |
| - client = described_class.new(mock_http_client, nil, spy_logger) |
89 |
| - mock_response = double('response', status_code: 500, json: nil) |
90 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
91 |
| - |
92 |
| - expect do |
93 |
| - client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
94 |
| - end.to raise_error(Optimizely::CmabFetchError, /500/) |
95 |
| - |
96 |
| - expect(mock_http_client).to have_received(:post).with( |
97 |
| - expected_url, |
98 |
| - hash_including( |
99 |
| - json: expected_body, |
100 |
| - headers: expected_headers, |
101 |
| - timeout: 10 |
102 |
| - ) |
103 |
| - ).once |
104 |
| - expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('500')) |
105 |
| - expect(Kernel).not_to have_received(:sleep) |
106 |
| - end |
| 157 | + result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
107 | 158 |
|
108 |
| - it 'should return invalid json without retrying' do |
109 |
| - client = described_class.new(mock_http_client, nil, spy_logger) |
110 |
| - mock_response = double('response', status_code: 200) |
111 |
| - allow(mock_response).to receive(:json).and_raise(JSON::ParserError.new('Expecting value')) |
112 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
113 |
| - |
114 |
| - expect do |
115 |
| - client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
116 |
| - end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/) |
117 |
| - |
118 |
| - expect(mock_http_client).to have_received(:post).with( |
119 |
| - expected_url, |
120 |
| - hash_including( |
121 |
| - json: expected_body, |
122 |
| - headers: expected_headers, |
123 |
| - timeout: 10 |
124 |
| - ) |
125 |
| - ).once |
126 |
| - expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response')) |
127 |
| - expect(Kernel).not_to have_received(:sleep) |
128 |
| - end |
| 159 | + expect(result).to eq('abc123') |
| 160 | + expect(mock_http_client).to have_received(:post).with( |
| 161 | + expected_url, |
| 162 | + hash_including( |
| 163 | + json: expected_body, |
| 164 | + headers: expected_headers, |
| 165 | + timeout: 10 |
| 166 | + ) |
| 167 | + ).once |
| 168 | + expect(Kernel).not_to have_received(:sleep) |
| 169 | + end |
129 | 170 |
|
130 |
| - it 'should return invalid response structure without retrying' do |
131 |
| - client = described_class.new(mock_http_client, nil, spy_logger) |
132 |
| - mock_response = double('response', status_code: 200, json: {'no_predictions' => []}) |
133 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
134 |
| - |
135 |
| - expect do |
136 |
| - client.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
137 |
| - end.to raise_error(Optimizely::CmabInvalidResponseError, /Invalid CMAB fetch response/) |
138 |
| - |
139 |
| - expect(mock_http_client).to have_received(:post).with( |
140 |
| - expected_url, |
141 |
| - hash_including( |
142 |
| - json: expected_body, |
143 |
| - headers: expected_headers, |
144 |
| - timeout: 10 |
145 |
| - ) |
146 |
| - ).once |
147 |
| - expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Invalid CMAB fetch response')) |
148 |
| - expect(Kernel).not_to have_received(:sleep) |
149 |
| - end |
| 171 | + it 'should return the variation id on third try' do |
| 172 | + failure_response = double('response', status_code: 500) |
| 173 | + success_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'xyz456'}]}) |
150 | 174 |
|
151 |
| - it 'should return the variation id on first try with retry config but no retry needed' do |
152 |
| - client_with_retry = described_class.new(mock_http_client, retry_config, spy_logger) |
153 |
| - |
154 |
| - # Mock successful response |
155 |
| - mock_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'abc123'}]}) |
156 |
| - allow(mock_http_client).to receive(:post).and_return(mock_response) |
157 |
| - |
158 |
| - result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
159 |
| - |
160 |
| - expect(result).to eq('abc123') |
161 |
| - expect(mock_http_client).to have_received(:post).with( |
162 |
| - expected_url, |
163 |
| - hash_including( |
164 |
| - json: expected_body, |
165 |
| - headers: expected_headers, |
166 |
| - timeout: 10 |
167 |
| - ) |
168 |
| - ).once |
169 |
| - expect(Kernel).not_to have_received(:sleep) |
170 |
| - end |
| 175 | + # Use a sequence to control responses |
| 176 | + allow(mock_http_client).to receive(:post).and_return(failure_response, failure_response, success_response) |
171 | 177 |
|
172 |
| - it 'should return the variation id on third try with retry config' do |
173 |
| - client_with_retry = described_class.new(mock_http_client, retry_config, spy_logger) |
| 178 | + result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
174 | 179 |
|
175 |
| - # Create failure and success responses |
176 |
| - failure_response = double('response', status_code: 500) |
177 |
| - success_response = double('response', status_code: 200, json: {'predictions' => [{'variationId' => 'xyz456'}]}) |
| 180 | + expect(result).to eq('xyz456') |
| 181 | + expect(mock_http_client).to have_received(:post).exactly(3).times |
178 | 182 |
|
179 |
| - # First two calls fail, third succeeds |
180 |
| - allow(mock_http_client).to receive(:post).and_return(failure_response, failure_response, success_response) |
181 |
| - |
182 |
| - result = client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
183 |
| - |
184 |
| - expect(result).to eq('xyz456') |
185 |
| - expect(mock_http_client).to have_received(:post).exactly(3).times |
186 |
| - |
187 |
| - # Verify retry logging |
188 |
| - expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once |
189 |
| - expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once |
190 |
| - expect(spy_logger).not_to have_received(:log).with(Logger::INFO, a_string_including('Retrying CMAB request (attempt 3)')) |
191 |
| - |
192 |
| - # Verify sleep was called with correct backoff times |
193 |
| - expect(Kernel).to have_received(:sleep).with(0.01).once |
194 |
| - expect(Kernel).to have_received(:sleep).with(0.02).once |
195 |
| - expect(Kernel).not_to have_received(:sleep).with(0.08) |
196 |
| - end |
| 183 | + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once |
| 184 | + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once |
| 185 | + expect(spy_logger).not_to have_received(:log).with(Logger::INFO, a_string_including('Retrying CMAB request (attempt 3)')) |
197 | 186 |
|
198 |
| - it 'should exhausts all retry attempts' do |
199 |
| - client_with_retry = described_class.new(mock_http_client, retry_config, spy_logger) |
| 187 | + expect(Kernel).to have_received(:sleep).with(0.01).once |
| 188 | + expect(Kernel).to have_received(:sleep).with(0.02).once |
| 189 | + expect(Kernel).not_to have_received(:sleep).with(0.04) |
| 190 | + expect(Kernel).not_to have_received(:sleep).with(0.08) |
| 191 | + end |
200 | 192 |
|
201 |
| - # Create failure response |
202 |
| - failure_response = double('response', status_code: 500) |
| 193 | + it 'should exhaust all retry attempts' do |
| 194 | + failure_response = double('response', status_code: 500) |
203 | 195 |
|
204 |
| - # All attempts fail |
205 |
| - allow(mock_http_client).to receive(:post).and_return(failure_response, failure_response, failure_response, failure_response) |
| 196 | + # All attempts fail |
| 197 | + allow(mock_http_client).to receive(:post).and_return(failure_response, failure_response, failure_response, failure_response) |
206 | 198 |
|
207 |
| - expect do |
208 |
| - client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
209 |
| - end.to raise_error(Optimizely::CmabFetchError) |
| 199 | + expect do |
| 200 | + client_with_retry.fetch_decision(rule_id, user_id, attributes, cmab_uuid) |
| 201 | + end.to raise_error(Optimizely::CmabFetchError) |
210 | 202 |
|
211 |
| - # Verify all attempts were made (1 initial + 3 retries = 4 calls) |
212 |
| - expect(mock_http_client).to have_received(:post).exactly(4).times |
| 203 | + expect(mock_http_client).to have_received(:post).exactly(4).times |
213 | 204 |
|
214 |
| - # Verify retry logging |
215 |
| - expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once |
216 |
| - expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once |
217 |
| - expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 3) after 0.08 seconds...').once |
| 205 | + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 1) after 0.01 seconds...').once |
| 206 | + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 2) after 0.02 seconds...').once |
| 207 | + expect(spy_logger).to have_received(:log).with(Logger::INFO, 'Retrying CMAB request (attempt 3) after 0.08 seconds...').once |
218 | 208 |
|
219 |
| - # Verify sleep was called for each retry |
220 |
| - expect(Kernel).to have_received(:sleep).with(0.01).once |
221 |
| - expect(Kernel).to have_received(:sleep).with(0.02).once |
222 |
| - expect(Kernel).to have_received(:sleep).with(0.08).once |
| 209 | + expect(Kernel).to have_received(:sleep).with(0.01).once |
| 210 | + expect(Kernel).to have_received(:sleep).with(0.02).once |
| 211 | + expect(Kernel).to have_received(:sleep).with(0.08).once |
223 | 212 |
|
224 |
| - # Verify final error logging |
225 |
| - expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Max retries exceeded for CMAB request')) |
| 213 | + expect(spy_logger).to have_received(:log).with(Logger::ERROR, a_string_including('Max retries exceeded for CMAB request')) |
| 214 | + end |
226 | 215 | end
|
227 | 216 | end
|
0 commit comments