amplitude-experiment 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/amplitude-experiment.gemspec +7 -6
  3. data/lib/amplitude-experiment.rb +16 -2
  4. data/lib/experiment/cohort/cohort.rb +25 -0
  5. data/lib/experiment/cohort/cohort_download_api.rb +90 -0
  6. data/lib/experiment/cohort/cohort_loader.rb +39 -0
  7. data/lib/experiment/cohort/cohort_storage.rb +91 -0
  8. data/lib/experiment/cohort/cohort_sync_config.rb +27 -0
  9. data/lib/experiment/deployment/deployment_runner.rb +135 -0
  10. data/lib/experiment/error.rb +23 -0
  11. data/lib/experiment/evaluation/evaluation.rb +311 -0
  12. data/lib/experiment/evaluation/flag.rb +123 -0
  13. data/lib/experiment/evaluation/murmur3.rb +104 -0
  14. data/lib/experiment/evaluation/select.rb +16 -0
  15. data/lib/experiment/evaluation/semantic_version.rb +52 -0
  16. data/lib/experiment/evaluation/topological_sort.rb +56 -0
  17. data/lib/experiment/{local/fetcher.rb → flag/flag_config_fetcher.rb} +7 -5
  18. data/lib/experiment/flag/flag_config_storage.rb +53 -0
  19. data/lib/experiment/local/client.rb +68 -29
  20. data/lib/experiment/local/config.rb +26 -2
  21. data/lib/experiment/persistent_http_client.rb +1 -1
  22. data/lib/experiment/remote/client.rb +8 -6
  23. data/lib/experiment/remote/config.rb +8 -1
  24. data/lib/experiment/user.rb +40 -20
  25. data/lib/experiment/util/flag_config.rb +60 -0
  26. data/lib/experiment/util/poller.rb +24 -0
  27. data/lib/experiment/util/user.rb +20 -12
  28. data/lib/experiment/version.rb +1 -1
  29. metadata +44 -25
  30. data/lib/experiment/local/evaluation/evaluation.rb +0 -76
  31. data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so +0 -0
  32. data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +0 -110
  33. data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so +0 -0
  34. data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +0 -110
  35. data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib +0 -0
  36. data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +0 -110
  37. data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib +0 -0
  38. data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +0 -110
  39. data/lib/experiment/util/topological_sort.rb +0 -39
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6df1a01ac720c72256be526321f9b30f93fcdde050ff6a0743c24bd53a64dd5b
4
- data.tar.gz: d10ebba30ae8a59b7964536909315059c8d096dc1b3a9d0e735c7242db400153
3
+ metadata.gz: 6d05f9400e7dd25e87eb6f372c0c1c7fa5c083fe392a75dd25152e0ad0c010a1
4
+ data.tar.gz: 19df424d3a56c5a44d6495282f3e1f5022e1a50c47b4a5e0fcfc53c0535576ea
5
5
  SHA512:
6
- metadata.gz: d623457be0fb4cc632f708365d53abbab2b7b647baf7ab37d18d6a05f21e321960e0632152ff2e60686d590e598cfa4333313cb6e31ad29868d2e4798be3e200
7
- data.tar.gz: e9800dd0da6e0edf6ee8c01bf289700e7f61963fc2e379ea55ddd54dda7c52c8b85f0185c07fcd2a3d2aa8b92391a0bda9828c794f1960d16e39f1e10ed15210
6
+ metadata.gz: 4e2e412e261f918990f23890200e9dc7b2cf0ed5b52e38fe585f10a3bd84f3b2d062c21a60dbf6811fdf27c395635f214f433d5dfb77c5573e3ae7e82a803352
7
+ data.tar.gz: 3bde003e6e5cb0d8b139218d408c5235949bed823f6973fb3f45f3eb542d7adfc7cdfe74f416bf62f06d6cd910eace6490bb6e4e3e6a0b7ef8f4abe686c854b4
@@ -14,20 +14,21 @@ Gem::Specification.new do |spec|
14
14
  spec.files = Dir['README.md',
15
15
  'lib/**/*.rb',
16
16
  'amplitude-experiment.gemspec',
17
- 'Gemfile',
18
- 'lib/experiment/local/evaluation/lib/**/*']
17
+ 'Gemfile']
19
18
  spec.require_paths = ['lib']
20
19
  spec.extra_rdoc_files = ['README.md']
21
20
 
22
- spec.add_development_dependency 'concurrent-ruby', '~> 1.2.2'
21
+ spec.add_dependency 'concurrent-ruby', '~> 1.2.2'
22
+
23
23
  spec.add_development_dependency 'psych', '~> 4.0'
24
24
  spec.add_development_dependency 'rake', '~> 13.0'
25
- spec.add_development_dependency 'rdoc', '= 6.4'
25
+ spec.add_development_dependency 'rdoc', '= 6.10'
26
26
  spec.add_development_dependency 'rspec', '~> 3.6'
27
- spec.add_development_dependency 'rubocop', '= 1.21'
27
+ spec.add_development_dependency 'rubocop', '= 1.22.3'
28
28
  spec.add_development_dependency 'simplecov', '~> 0.21'
29
29
  spec.add_development_dependency 'webmock', '~> 3.14'
30
30
  spec.add_development_dependency 'yard', '~> 0.9'
31
+ spec.add_development_dependency 'dotenv', '~> 2.8.1'
32
+ spec.add_development_dependency 'jar-dependencies', '= 0.4.1'
31
33
  spec.metadata['rubygems_mfa_required'] = 'false'
32
- spec.add_runtime_dependency 'ffi', '~> 1.15'
33
34
  end
@@ -8,17 +8,31 @@ require 'experiment/factory'
8
8
  require 'experiment/remote/client'
9
9
  require 'experiment/local/client'
10
10
  require 'experiment/local/config'
11
- require 'experiment/local/fetcher'
12
11
  require 'experiment/local/assignment/assignment'
13
12
  require 'experiment/local/assignment/assignment_filter'
14
13
  require 'experiment/local/assignment/assignment_service'
15
14
  require 'experiment/local/assignment/assignment_config'
16
15
  require 'experiment/util/lru_cache'
17
16
  require 'experiment/util/hash'
18
- require 'experiment/util/topological_sort'
19
17
  require 'experiment/util/user'
20
18
  require 'experiment/util/variant'
21
19
  require 'experiment/error'
20
+ require 'experiment/util/flag_config'
21
+ require 'experiment/flag/flag_config_fetcher'
22
+ require 'experiment/flag/flag_config_storage'
23
+ require 'experiment/cohort/cohort_download_api'
24
+ require 'experiment/cohort/cohort'
25
+ require 'experiment/cohort/cohort_loader'
26
+ require 'experiment/cohort/cohort_storage'
27
+ require 'experiment/cohort/cohort_sync_config'
28
+ require 'experiment/deployment/deployment_runner'
29
+ require 'experiment/util/poller'
30
+ require 'experiment/evaluation/evaluation'
31
+ require 'experiment/evaluation/flag'
32
+ require 'experiment/evaluation/murmur3'
33
+ require 'experiment/evaluation/select'
34
+ require 'experiment/evaluation/semantic_version'
35
+ require 'experiment/evaluation/topological_sort'
22
36
 
23
37
  # Amplitude Experiment Module
24
38
  module AmplitudeExperiment
@@ -0,0 +1,25 @@
1
+ module AmplitudeExperiment
2
+ USER_GROUP_TYPE = 'User'.freeze
3
+ # Cohort
4
+ class Cohort
5
+ attr_accessor :id, :last_modified, :size, :member_ids, :group_type
6
+
7
+ def initialize(id, last_modified, size, member_ids, group_type = USER_GROUP_TYPE)
8
+ @id = id
9
+ @last_modified = last_modified
10
+ @size = size
11
+ @member_ids = member_ids.to_set
12
+ @group_type = group_type
13
+ end
14
+
15
+ def ==(other)
16
+ return false unless other.is_a?(Cohort)
17
+
18
+ @id == other.id &&
19
+ @last_modified == other.last_modified &&
20
+ @size == other.size &&
21
+ @member_ids == other.member_ids &&
22
+ @group_type == other.group_type
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,90 @@
1
+ require 'base64'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'set'
6
+
7
+ module AmplitudeExperiment
8
+ # CohortDownloadApi
9
+ class CohortDownloadApi
10
+ COHORT_REQUEST_TIMEOUT_MILLIS = 5000
11
+ COHORT_REQUEST_RETRY_DELAY_MILLIS = 100
12
+
13
+ def get_cohort(cohort_id, cohort = nil)
14
+ raise NotImplementedError
15
+ end
16
+ end
17
+
18
+ # DirectCohortDownloadApi
19
+ class DirectCohortDownloadApi < CohortDownloadApi
20
+ def initialize(api_key, secret_key, max_cohort_size, server_url, logger)
21
+ super()
22
+ @api_key = api_key
23
+ @secret_key = secret_key
24
+ @max_cohort_size = max_cohort_size
25
+ @server_url = server_url
26
+ @logger = logger
27
+ end
28
+
29
+ def get_cohort(cohort_id, cohort = nil)
30
+ @logger.debug("getCohortMembers(#{cohort_id}): start")
31
+ errors = 0
32
+
33
+ loop do
34
+ begin
35
+ last_modified = cohort.nil? ? nil : cohort.last_modified
36
+ response = get_cohort_members_request(cohort_id, last_modified)
37
+ @logger.debug("getCohortMembers(#{cohort_id}): status=#{response.code}")
38
+
39
+ case response.code.to_i
40
+ when 200
41
+ cohort_info = JSON.parse(response.body)
42
+ @logger.debug("getCohortMembers(#{cohort_id}): end - resultSize=#{cohort_info['size']}")
43
+ return Cohort.new(
44
+ cohort_info['cohortId'],
45
+ cohort_info['lastModified'],
46
+ cohort_info['size'],
47
+ cohort_info['memberIds'].to_set,
48
+ cohort_info['groupType']
49
+ )
50
+ when 204
51
+ @logger.debug("getCohortMembers(#{cohort_id}): Cohort not modified")
52
+ return nil
53
+ when 413
54
+ raise CohortTooLargeError.new(cohort_id, "Cohort exceeds max cohort size: #{response.code}")
55
+ else
56
+ raise HTTPErrorResponseError.new(response.code, cohort_id, "Unexpected response code: #{response.code}") if response.code.to_i != 202
57
+
58
+ end
59
+ rescue StandardError => e
60
+ errors += 1 unless response && e.is_a?(HTTPErrorResponseError) && response.code.to_i == 429
61
+ @logger.debug("getCohortMembers(#{cohort_id}): request-status error #{errors} - #{e}")
62
+ raise e if errors >= 3 || e.is_a?(CohortTooLargeError)
63
+ end
64
+
65
+ sleep(COHORT_REQUEST_RETRY_DELAY_MILLIS / 1000.0)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def get_cohort_members_request(cohort_id, last_modified)
72
+ headers = {
73
+ 'Authorization' => "Basic #{basic_auth}",
74
+ 'Content-Type' => 'application/json;charset=utf-8',
75
+ 'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
76
+ }
77
+ url = "#{@server_url}/sdk/v1/cohort/#{cohort_id}?maxCohortSize=#{@max_cohort_size}"
78
+ url += "&lastModified=#{last_modified}" if last_modified
79
+
80
+ request = Net::HTTP::Get.new(URI(url), headers)
81
+ http = PersistentHttpClient.get(@server_url, { read_timeout: COHORT_REQUEST_TIMEOUT_MILLIS }, basic_auth)
82
+ http.request(request)
83
+ end
84
+
85
+ def basic_auth
86
+ credentials = "#{@api_key}:#{@secret_key}"
87
+ Base64.strict_encode64(credentials)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,39 @@
1
+ module AmplitudeExperiment
2
+ # CohortLoader
3
+ class CohortLoader
4
+ def initialize(cohort_download_api, cohort_storage)
5
+ @cohort_download_api = cohort_download_api
6
+ @cohort_storage = cohort_storage
7
+ @jobs = {}
8
+ @lock_jobs = Mutex.new
9
+ end
10
+
11
+ def load_cohort(cohort_id)
12
+ @lock_jobs.synchronize do
13
+ unless @jobs.key?(cohort_id)
14
+ future = Concurrent::Promises.future do
15
+ load_cohort_internal(cohort_id)
16
+ ensure
17
+ remove_job(cohort_id)
18
+ end
19
+ @jobs[cohort_id] = future
20
+ end
21
+ @jobs[cohort_id]
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def load_cohort_internal(cohort_id)
28
+ stored_cohort = @cohort_storage.cohort(cohort_id)
29
+ updated_cohort = @cohort_download_api.get_cohort(cohort_id, stored_cohort)
30
+ @cohort_storage.put_cohort(updated_cohort) unless updated_cohort.nil?
31
+ end
32
+
33
+ def remove_job(cohort_id)
34
+ @lock_jobs.synchronize do
35
+ @jobs.delete(cohort_id)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,91 @@
1
+ module AmplitudeExperiment
2
+ # CohortStorage
3
+ class CohortStorage
4
+ def cohort(cohort_id)
5
+ raise NotImplementedError
6
+ end
7
+
8
+ def cohorts
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def get_cohorts_for_user(user_id, cohort_ids)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def get_cohorts_for_group(group_type, group_name, cohort_ids)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def put_cohort(cohort_description)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def delete_cohort(group_type, cohort_id)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def cohort_ids
29
+ raise NotImplementedError
30
+ end
31
+ end
32
+
33
+ class InMemoryCohortStorage < CohortStorage
34
+ def initialize
35
+ super
36
+ @lock = Mutex.new
37
+ @group_to_cohort_store = {}
38
+ @cohort_store = {}
39
+ end
40
+
41
+ def cohort(cohort_id)
42
+ @lock.synchronize do
43
+ @cohort_store[cohort_id]
44
+ end
45
+ end
46
+
47
+ def cohorts
48
+ @lock.synchronize do
49
+ @cohort_store.dup
50
+ end
51
+ end
52
+
53
+ def get_cohorts_for_user(user_id, cohort_ids)
54
+ get_cohorts_for_group(USER_GROUP_TYPE, user_id, cohort_ids)
55
+ end
56
+
57
+ def get_cohorts_for_group(group_type, group_name, cohort_ids)
58
+ result = Set.new
59
+ @lock.synchronize do
60
+ group_type_cohorts = @group_to_cohort_store[group_type] || Set.new
61
+ group_type_cohorts.each do |cohort_id|
62
+ members = @cohort_store[cohort_id]&.member_ids || Set.new
63
+ result.add(cohort_id) if cohort_ids.include?(cohort_id) && members.include?(group_name)
64
+ end
65
+ end
66
+ result
67
+ end
68
+
69
+ def put_cohort(cohort)
70
+ @lock.synchronize do
71
+ @group_to_cohort_store[cohort.group_type] ||= Set.new
72
+ @group_to_cohort_store[cohort.group_type].add(cohort.id)
73
+ @cohort_store[cohort.id] = cohort
74
+ end
75
+ end
76
+
77
+ def delete_cohort(group_type, cohort_id)
78
+ @lock.synchronize do
79
+ group_cohorts = @group_to_cohort_store[group_type] || Set.new
80
+ group_cohorts.delete(cohort_id)
81
+ @cohort_store.delete(cohort_id)
82
+ end
83
+ end
84
+
85
+ def cohort_ids
86
+ @lock.synchronize do
87
+ @cohort_store.keys.to_set
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,27 @@
1
+ module AmplitudeExperiment
2
+ DEFAULT_COHORT_SYNC_URL = 'https://cohort-v2.lab.amplitude.com'.freeze
3
+ EU_COHORT_SYNC_URL = 'https://cohort-v2.lab.eu.amplitude.com'.freeze
4
+
5
+ # Experiment Cohort Sync Configuration
6
+ class CohortSyncConfig
7
+ # This configuration is used to set up the cohort loader. The cohort loader is responsible for
8
+ # downloading cohorts from the server and storing them locally.
9
+ # Parameters:
10
+ # api_key (str): The project API Key
11
+ # secret_key (str): The project Secret Key
12
+ # max_cohort_size (int): The maximum cohort size that can be downloaded
13
+ # cohort_polling_interval_millis (int): The interval in milliseconds to poll for cohorts, the minimum value is 60000
14
+ # cohort_server_url (str): The server endpoint from which to request cohorts
15
+
16
+ attr_accessor :api_key, :secret_key, :max_cohort_size, :cohort_polling_interval_millis, :cohort_server_url
17
+
18
+ def initialize(api_key, secret_key, max_cohort_size: 2_147_483_647, cohort_polling_interval_millis: 60_000,
19
+ cohort_server_url: DEFAULT_COHORT_SYNC_URL)
20
+ @api_key = api_key
21
+ @secret_key = secret_key
22
+ @max_cohort_size = max_cohort_size
23
+ @cohort_polling_interval_millis = [cohort_polling_interval_millis, 60_000].max
24
+ @cohort_server_url = cohort_server_url
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,135 @@
1
+ require 'set'
2
+
3
+ module AmplitudeExperiment
4
+ # DeploymentRunner
5
+ class DeploymentRunner
6
+ def initialize(
7
+ config,
8
+ flag_config_fetcher,
9
+ flag_config_storage,
10
+ cohort_storage,
11
+ logger,
12
+ cohort_loader = nil
13
+ )
14
+ @config = config
15
+ @flag_config_fetcher = flag_config_fetcher
16
+ @flag_config_storage = flag_config_storage
17
+ @cohort_storage = cohort_storage
18
+ @cohort_loader = cohort_loader
19
+ @lock = Mutex.new
20
+ @logger = logger
21
+ @executor = Concurrent::ThreadPoolExecutor.new(
22
+ max_threads: 10,
23
+ name: 'DeploymentRunnerExecutor'
24
+ )
25
+ end
26
+
27
+ def start
28
+ @lock.synchronize do
29
+ update_flag_configs
30
+ @flag_poller = Poller.new(
31
+ @config.flag_config_polling_interval_millis / 1000.0,
32
+ method(:periodic_flag_update)
33
+ )
34
+ @flag_poller.start
35
+ if @config.cohort_sync_config
36
+ @cohort_poller = Poller.new(
37
+ @config.cohort_sync_config.cohort_polling_interval_millis / 1000.0,
38
+ method(:update_cohorts)
39
+ )
40
+ @cohort_poller.start
41
+ end
42
+ end
43
+ end
44
+
45
+ def stop
46
+ @flag_poller&.stop
47
+ @flag_poller = nil
48
+ @cohort_poller&.stop
49
+ @cohort_poller = nil
50
+ end
51
+
52
+ private
53
+
54
+ def periodic_flag_update
55
+ @logger.debug('Periodic flag update: start')
56
+ update_flag_configs
57
+ rescue StandardError => e
58
+ @logger.error("Error while updating flags: #{e}")
59
+ end
60
+
61
+ def update_flag_configs
62
+ flags = @flag_config_fetcher.fetch_v2
63
+ flag_configs = flags.map { |f| [f.key, f] }.to_h
64
+ flag_keys = flag_configs.keys.to_set
65
+ @flag_config_storage.remove_if { |f| !flag_keys.include?(f.key) }
66
+
67
+ unless @cohort_loader
68
+ flag_configs.each do |flag_key, flag_config|
69
+ @logger.debug("Putting non-cohort flag #{flag_key}")
70
+ @flag_config_storage.put_flag_config(flag_config)
71
+ end
72
+ return
73
+ end
74
+
75
+ new_cohort_ids = Set.new
76
+ flag_configs.each do |_, flag_config|
77
+ new_cohort_ids.merge(AmplitudeExperiment.get_all_cohort_ids_from_flag(flag_config))
78
+ end
79
+
80
+ existing_cohort_ids = @cohort_storage.cohort_ids
81
+ cohort_ids_to_download = new_cohort_ids - existing_cohort_ids
82
+
83
+ download_cohorts(cohort_ids_to_download)
84
+
85
+ updated_cohort_ids = @cohort_storage.cohort_ids
86
+
87
+ flag_configs.each do |flag_key, flag_config|
88
+ cohort_ids = AmplitudeExperiment.get_all_cohort_ids_from_flag(flag_config)
89
+ @logger.debug("Storing flag #{flag_key}")
90
+ @flag_config_storage.put_flag_config(flag_config)
91
+ missing_cohorts = cohort_ids - updated_cohort_ids
92
+
93
+ @logger.warn("Flag #{flag_key} - failed to load cohorts: #{missing_cohorts}") if missing_cohorts.any?
94
+ end
95
+
96
+ delete_unused_cohorts
97
+ @logger.debug("Refreshed #{flag_configs.size} flag configs.")
98
+ end
99
+
100
+ def download_cohorts(cohort_ids)
101
+ futures = cohort_ids.map do |cohort_id|
102
+ Concurrent::Promises.future_on(@executor) do
103
+ future = @cohort_loader.load_cohort(cohort_id)
104
+ future.value!
105
+ rescue StandardError => e
106
+ @logger.error("Failed to download cohort #{cohort_id}: #{e.message}")
107
+ nil
108
+ end
109
+ end
110
+
111
+ Concurrent::Promises.zip(*futures).value!
112
+ end
113
+
114
+ def update_cohorts
115
+ @logger.debug('Periodic cohort update: start')
116
+ cohort_ids = AmplitudeExperiment.get_all_cohort_ids_from_flags(@flag_config_storage.flag_configs)
117
+ download_cohorts(cohort_ids)
118
+ end
119
+
120
+ def delete_unused_cohorts
121
+ flag_cohort_ids = Set.new
122
+ @flag_config_storage.flag_configs.each do |_, flag|
123
+ flag_cohort_ids.merge(AmplitudeExperiment.get_all_cohort_ids_from_flag(flag))
124
+ end
125
+
126
+ storage_cohorts = @cohort_storage.cohorts
127
+ deleted_cohort_ids = storage_cohorts.keys.to_set - flag_cohort_ids
128
+
129
+ deleted_cohort_ids.each do |deleted_cohort_id|
130
+ deleted_cohort = storage_cohorts[deleted_cohort_id]
131
+ @cohort_storage.delete_cohort(deleted_cohort.group_type, deleted_cohort_id) if deleted_cohort
132
+ end
133
+ end
134
+ end
135
+ end
@@ -9,6 +9,29 @@ module AmplitudeExperiment
9
9
  end
10
10
  end
11
11
 
12
+ class CohortDownloadError < StandardError
13
+ attr_reader :cohort_id
14
+
15
+ def initialize(cohort_id, message)
16
+ super(message)
17
+ @cohort_id = cohort_id
18
+ end
19
+ end
20
+
21
+ # CohortTooLargeError
22
+ class CohortTooLargeError < CohortDownloadError
23
+ end
24
+
25
+ # HTTPErrorResponseError
26
+ class HTTPErrorResponseError < CohortDownloadError
27
+ attr_reader :status_code
28
+
29
+ def initialize(status_code, cohort_id, message)
30
+ super(cohort_id, message)
31
+ @status_code = status_code
32
+ end
33
+ end
34
+
12
35
  class CycleError < StandardError
13
36
  # Raised when topological sorting encounters a cycle between flag dependencies.
14
37
  attr_reader :path