modal-rb 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3d89ed493f174adebf2f5a015923904757963cae4a650fd949eb66cab0267dff
4
+ data.tar.gz: ff2a215607081d69ba5c164460e970a857b7f91bd7781fa44a035e380feb51a9
5
+ SHA512:
6
+ metadata.gz: 2d319697b1697a8092ef7aa6644785fbf61e6ee3594740322f8736687eac163e859ad0796845bc05c3705d386e3a15a366dc5689ffb7aa938f15f15f6d980086
7
+ data.tar.gz: eee6a3d7c4d60075b67e92e7ec4b0150f0def437313175cd79004368831ca397233dd6af879c38800b57ec77d133270d42ab5710a6af0be5e821c678dfa1c61a
@@ -0,0 +1,111 @@
1
+ require 'grpc'
2
+ require 'securerandom'
3
+
4
+ module Modal
5
+ class ApiClient
6
+ RETRYABLE_GRPC_STATUS_CODES = Set.new([
7
+ GRPC::Core::StatusCodes::DEADLINE_EXCEEDED,
8
+ GRPC::Core::StatusCodes::UNAVAILABLE,
9
+ GRPC::Core::StatusCodes::CANCELLED,
10
+ GRPC::Core::StatusCodes::INTERNAL,
11
+ GRPC::Core::StatusCodes::UNKNOWN,
12
+ ])
13
+
14
+ def initialize
15
+ @profile = Config.profile
16
+ target, credentials = parse_server_url(@profile[:server_url])
17
+
18
+ @stub = Modal::Client::ModalClient::Stub.new(
19
+ target,
20
+ credentials,
21
+ channel_args: {
22
+ 'grpc.max_receive_message_length' => 100 * 1024 * 1024, # 100 MiB
23
+ 'grpc.max_send_message_length' => 100 * 1024 * 1024, # 100 MiB
24
+ }
25
+ )
26
+ end
27
+
28
+ def call(method_name, request_pb, options = {})
29
+ retries = options[:retries] || 3
30
+ base_delay = options[:base_delay] || 0.1 # seconds
31
+ max_delay = options[:max_delay] || 1.0 # seconds
32
+ delay_factor = options[:delay_factor] || 2
33
+ timeout = options[:timeout] # milliseconds
34
+
35
+ idempotency_key = SecureRandom.uuid
36
+ attempt = 0
37
+
38
+ loop do
39
+ metadata = {
40
+ 'x-modal-client-type' => Modal::Client::ClientType::CLIENT_TYPE_LIBMODAL.to_s, # TODO: libmodal_rb!!!
41
+ 'x-modal-client-version' => '1.0.0',
42
+ 'x-modal-token-id' => @profile[:token_id],
43
+ 'x-modal-token-secret' => @profile[:token_secret],
44
+ 'x-idempotency-key' => idempotency_key,
45
+ 'x-retry-attempt' => attempt.to_s,
46
+ }
47
+ metadata['x-retry-delay'] = base_delay.to_s if attempt > 0
48
+
49
+ call_options = { metadata: metadata }
50
+ call_options[:deadline] = Time.now + timeout / 1000.0 if timeout
51
+
52
+ begin
53
+ response = @stub.send(method_name, request_pb, call_options)
54
+ return response
55
+ rescue GRPC::BadStatus => e
56
+ if RETRYABLE_GRPC_STATUS_CODES.include?(e.code) && attempt < retries
57
+ puts "Retrying #{method_name} due to #{e.code} (attempt #{attempt + 1}/#{retries})"
58
+ sleep(base_delay)
59
+ base_delay = [base_delay * delay_factor, max_delay].min
60
+ attempt += 1
61
+ else
62
+ raise convert_grpc_error(e)
63
+ end
64
+ rescue StandardError => e
65
+ raise e
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def parse_server_url(server_url)
73
+ if server_url.start_with?('https://')
74
+ target = server_url.sub('https://', '')
75
+ credentials = GRPC::Core::ChannelCredentials.new
76
+ elsif server_url.start_with?('http://')
77
+ target = server_url.sub('http://', '')
78
+ credentials = :this_channel_is_insecure
79
+ else
80
+ target = server_url
81
+ credentials = GRPC::Core::ChannelCredentials.new
82
+ end
83
+
84
+ [target, credentials]
85
+ end
86
+
87
+ def convert_grpc_error(grpc_error)
88
+ case grpc_error.code
89
+ when GRPC::Core::StatusCodes::NOT_FOUND
90
+ NotFoundError.new(grpc_error.details)
91
+ when GRPC::Core::StatusCodes::FAILED_PRECONDITION
92
+ if grpc_error.details.include?("Secret is missing key")
93
+ NotFoundError.new(grpc_error.details)
94
+ else
95
+ InvalidError.new(grpc_error.details)
96
+ end
97
+ when GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
98
+ FunctionTimeoutError.new(grpc_error.details)
99
+ when GRPC::Core::StatusCodes::INTERNAL, GRPC::Core::StatusCodes::UNKNOWN
100
+ InternalFailure.new(grpc_error.details)
101
+ else
102
+ RemoteError.new(grpc_error.details)
103
+ end
104
+ end
105
+ end
106
+
107
+ @client = ApiClient.new
108
+ def self.client
109
+ @client
110
+ end
111
+ end
data/lib/modal/app.rb ADDED
@@ -0,0 +1,65 @@
1
+ module Modal
2
+ class App
3
+ attr_reader :app_id
4
+
5
+ def initialize(app_id)
6
+ @app_id = app_id
7
+ end
8
+
9
+ def self.lookup(name, options = {})
10
+ create_if_missing = options[:create_if_missing] || false
11
+ environment = options[:environment]
12
+
13
+ request = Modal::Client::AppGetOrCreateRequest.new(
14
+ app_name: name,
15
+ environment_name: Config.environment_name(environment),
16
+ object_creation_type: create_if_missing ? Modal::Client::ObjectCreationType::OBJECT_CREATION_TYPE_CREATE_IF_MISSING : Modal::Client::ObjectCreationType::OBJECT_CREATION_TYPE_UNSPECIFIED
17
+ )
18
+
19
+ resp = Modal.client.call(:app_get_or_create, request)
20
+ new(resp.app_id)
21
+ end
22
+
23
+ def create_sandbox(image, options = {})
24
+ timeout_secs = options[:timeout] ? options[:timeout] / 1000 : 600
25
+ cpu_milli = (options[:cpu] || 0.125) * 1000
26
+ memory_mb = options[:memory] || 128
27
+ command = options[:command] || ["sleep", "48h"]
28
+
29
+ request = Modal::Client::SandboxCreateRequest.new(
30
+ app_id: @app_id,
31
+ definition: Modal::Client::Sandbox.new(
32
+ entrypoint_args: command,
33
+ image_id: image.image_id,
34
+ timeout_secs: timeout_secs,
35
+ network_access: Modal::Client::NetworkAccess.new(
36
+ network_access_type: Modal::Client::NetworkAccess::NetworkAccessType::OPEN
37
+ ),
38
+ resources: Modal::Client::Resources.new(
39
+ milli_cpu: cpu_milli.round,
40
+ memory_mb: memory_mb.round
41
+ )
42
+ )
43
+ )
44
+
45
+ create_resp = Modal.client.call(:sandbox_create, request)
46
+ Sandbox.new(create_resp.sandbox_id)
47
+ end
48
+
49
+ def image_from_registry(tag)
50
+ Image.from_registry_internal(@app_id, tag)
51
+ end
52
+
53
+ def image_from_aws_ecr(tag, secret)
54
+ unless secret.is_a?(Secret)
55
+ raise TypeError, "secret must be a reference to an existing Secret, e.g. `Secret.from_name('my_secret')`"
56
+ end
57
+
58
+ image_registry_config = Modal::Client::ImageRegistryConfig.new(
59
+ registry_auth_type: Modal::Client::RegistryAuthType::REGISTRY_AUTH_TYPE_AWS,
60
+ secret_id: secret.secret_id
61
+ )
62
+ Image.from_registry_internal(@app_id, tag, image_registry_config)
63
+ end
64
+ end
65
+ end
data/lib/modal/cls.rb ADDED
@@ -0,0 +1,131 @@
1
+ module Modal
2
+ class Cls
3
+ attr_reader :service_function_id, :schema, :method_names
4
+
5
+ def initialize(service_function_id, schema, method_names)
6
+ @service_function_id = service_function_id
7
+ @schema = schema
8
+ @method_names = method_names
9
+ end
10
+
11
+ def self.lookup(app_name, name, options = {})
12
+ service_function_name = "#{name}.*"
13
+ request = Modal::Client::FunctionGetRequest.new(
14
+ app_name: app_name,
15
+ object_tag: service_function_name,
16
+ environment_name: Config.environment_name(options[:environment])
17
+ )
18
+
19
+ service_function = Modal.client.call(:function_get, request)
20
+ parameter_info = service_function.handle_metadata&.class_parameter_info
21
+ schema = parameter_info&.schema || []
22
+
23
+ if schema.any? && parameter_info.format != Modal::Client::ClassParameterInfo_ParameterSerializationFormat::PARAM_SERIALIZATION_FORMAT_PROTO
24
+ raise "Unsupported parameter format: #{parameter_info.format}"
25
+ end
26
+
27
+ method_names = if service_function.handle_metadata&.method_handle_metadata
28
+ service_function.handle_metadata.method_handle_metadata.keys
29
+ else
30
+ raise "Cls requires Modal deployments using client v0.67 or later."
31
+ end
32
+
33
+ new(service_function.function_id, schema, method_names)
34
+ rescue NotFoundError => e
35
+ raise NotFoundError.new("Class '#{app_name}/#{name}' not found")
36
+ end
37
+
38
+ def instance(params = {})
39
+ function_id = if @schema.empty?
40
+ @service_function_id
41
+ else
42
+ bind_parameters(params)
43
+ end
44
+
45
+ methods = {}
46
+ @method_names.each do |name|
47
+ methods[name] = Function_.new(function_id, name)
48
+ end
49
+ ClsInstance.new(methods)
50
+ end
51
+
52
+ private
53
+
54
+ def bind_parameters(params)
55
+ serialized_params = encode_parameter_set(@schema, params)
56
+ request = Modal::Client::FunctionBindParamsRequest.new(
57
+ function_id: @service_function_id,
58
+ serialized_params: serialized_params
59
+ )
60
+ bind_resp = Modal.client.call(:function_bind_params, request)
61
+ bind_resp.bound_function_id
62
+ end
63
+
64
+ def encode_parameter_set(schema, params)
65
+ encoded_params = schema.map do |param_spec|
66
+ encode_parameter(param_spec, params[param_spec.name.to_sym])
67
+ end
68
+
69
+ encoded_params.sort_by!(&:name)
70
+ Modal::Client::ClassParameterSet.encode(parameters: encoded_params).to_proto
71
+ end
72
+
73
+ def encode_parameter(param_spec, value)
74
+ name = param_spec.name
75
+ param_type = param_spec.type
76
+ param_value = Modal::Client::ClassParameterValue.new(name: name, type: param_type)
77
+
78
+ case param_type
79
+ when Modal::Client::ParameterType::PARAM_TYPE_STRING
80
+ if value.nil? && param_spec.has_default
81
+ value = param_spec.string_default || ""
82
+ end
83
+ unless value.is_a?(String)
84
+ raise "Parameter '#{name}' must be a string"
85
+ end
86
+ param_value.string_value = value
87
+ when Modal::Client::ParameterType::PARAM_TYPE_INT
88
+ if value.nil? && param_spec.has_default
89
+ value = param_spec.int_default || 0
90
+ end
91
+ unless value.is_a?(Integer)
92
+ raise "Parameter '#{name}' must be an integer"
93
+ end
94
+ param_value.int_value = value
95
+ when Modal::Client::ParameterType::PARAM_TYPE_BOOL
96
+ if value.nil? && param_spec.has_default
97
+ value = param_spec.bool_default || false
98
+ end
99
+ unless [true, false].include?(value)
100
+ raise "Parameter '#{name}' must be a boolean"
101
+ end
102
+ param_value.bool_value = value
103
+ when Modal::Client::ParameterType::PARAM_TYPE_BYTES
104
+ if value.nil? && param_spec.has_default
105
+ value = param_spec.bytes_default || ""
106
+ end
107
+ unless value.is_a?(String)
108
+ raise "Parameter '#{name}' must be a byte array (String in Ruby)"
109
+ end
110
+ param_value.bytes_value = value.bytes.pack('C*')
111
+ else
112
+ raise "Unsupported parameter type: #{param_type}"
113
+ end
114
+ param_value
115
+ end
116
+ end
117
+
118
+ class ClsInstance
119
+ def initialize(methods)
120
+ @methods = methods
121
+ end
122
+
123
+ def method(name)
124
+ func = @methods[name.to_s]
125
+ unless func
126
+ raise NotFoundError.new("Method '#{name}' not found on class")
127
+ end
128
+ func
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,69 @@
1
+ require 'toml-rb'
2
+ require 'fileutils'
3
+
4
+ module Modal
5
+ module Config
6
+ CONFIG_FILE = File.join(Dir.home, '.modal.toml')
7
+
8
+ def self.read_config_file
9
+ if File.exist?(CONFIG_FILE)
10
+ TomlRB.parse(File.read(CONFIG_FILE))
11
+ else
12
+ {}
13
+ end
14
+ rescue StandardError => e
15
+ raise "Failed to read or parse .modal.toml: #{e.message}"
16
+ end
17
+
18
+ def self.get_profile(profile_name = nil)
19
+ config = read_config_file
20
+
21
+ profile_name ||= ENV['MODAL_PROFILE']
22
+ unless profile_name
23
+ config.each do |name, data|
24
+ if data['active']
25
+ profile_name = name
26
+ break
27
+ end
28
+ end
29
+ end
30
+
31
+ if profile_name && !config.key?(profile_name)
32
+ raise "Profile \"#{profile_name}\" not found in .modal.toml. Please set the MODAL_PROFILE environment variable or specify a valid profile."
33
+ end
34
+
35
+ profile_data = profile_name ? config[profile_name] : {}
36
+
37
+ server_url = ENV['MODAL_SERVER_URL'] || profile_data['server_url'] || 'https://api.modal.com'
38
+ token_id = ENV['MODAL_TOKEN_ID'] || profile_data['token_id']
39
+ token_secret = ENV['MODAL_TOKEN_SECRET'] || profile_data['token_secret']
40
+ environment = ENV['MODAL_ENVIRONMENT'] || profile_data['environment']
41
+ image_builder_version = ENV['MODAL_IMAGE_BUILDER_VERSION'] || profile_data['image_builder_version'] || '2024.10'
42
+
43
+ unless token_id && token_secret
44
+ raise "Profile \"#{profile_name}\" is missing token_id or token_secret. Please set them in .modal.toml or as environment variables."
45
+ end
46
+
47
+ {
48
+ server_url: server_url,
49
+ token_id: token_id,
50
+ token_secret: token_secret,
51
+ environment: environment,
52
+ image_builder_version: image_builder_version
53
+ }
54
+ end
55
+
56
+ def self.environment_name(environment = nil)
57
+ environment || profile[:environment] || ""
58
+ end
59
+
60
+ def self.image_builder_version(version = nil)
61
+ version || profile[:image_builder_version] || "2024.10"
62
+ end
63
+
64
+ @profile = get_profile
65
+ def self.profile
66
+ @profile
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,11 @@
1
+ module Modal
2
+ class ModalError < StandardError; end
3
+ class FunctionTimeoutError < ModalError; end
4
+ class RemoteError < ModalError; end
5
+ class InternalFailure < ModalError; end
6
+ class NotFoundError < ModalError; end
7
+ class InvalidError < ModalError; end
8
+ class QueueEmptyError < ModalError; end
9
+ class QueueFullError < ModalError; end
10
+ class SandboxFilesystemError < ModalError; end
11
+ end
@@ -0,0 +1,122 @@
1
+ require 'digest'
2
+ require_relative 'pickle'
3
+
4
+ module Modal
5
+ class Function_
6
+ attr_reader :function_id, :method_name
7
+
8
+ MAX_OBJECT_SIZE_BYTES = 2 * 1024 * 1024 # 2 MiB
9
+ MAX_SYSTEM_RETRIES = 8
10
+
11
+ def initialize(function_id, method_name = nil)
12
+ @function_id = function_id
13
+ @method_name = method_name
14
+ end
15
+
16
+ def self.lookup(app_name, name, options = {})
17
+ environment = options[:environment] || Modal::Config.environment_name
18
+ request = Modal::Client::FunctionGetRequest.new(
19
+ app_name: app_name,
20
+ object_tag: name,
21
+ environment_name: Config.environment_name(environment)
22
+ )
23
+ resp = Modal.client.call(:function_get, request)
24
+ new(resp.function_id)
25
+ rescue NotFoundError
26
+ raise NotFoundError.new("Function '#{app_name}/#{name}' not found")
27
+ end
28
+
29
+ def remote(args = [], kwargs = {})
30
+ input = create_input(args, kwargs)
31
+ invocation = ControlPlaneInvocation.create(@function_id, input, Modal::Client::FunctionCallInvocationType::FUNCTION_CALL_INVOCATION_TYPE_SYNC)
32
+
33
+ retry_count = 0
34
+ loop do
35
+ begin
36
+ return invocation.await_output
37
+ rescue InternalFailure => e
38
+ if retry_count <= MAX_SYSTEM_RETRIES
39
+ invocation.retry(retry_count)
40
+ retry_count += 1
41
+ else
42
+ raise e
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def spawn(args = [], kwargs = {})
49
+ input = create_input(args, kwargs)
50
+ invocation = ControlPlaneInvocation.create(@function_id, input, Modal::Client::FunctionCallInvocationType::FUNCTION_CALL_INVOCATION_TYPE_ASYNC)
51
+ FunctionCall.new(invocation.function_call_id)
52
+ end
53
+
54
+ private
55
+
56
+ def create_input(args, kwargs)
57
+ # Create a proper Python tuple structure
58
+ # The Python function expects to receive (*args, **kwargs)
59
+ # So we need to create a tuple where:
60
+ # - First element is a tuple of positional args
61
+ # - Second element is a dict of keyword args
62
+
63
+ # Convert Ruby array to Python tuple, Ruby hash to Python dict
64
+ python_args = args.is_a?(Array) ? args : [args]
65
+ python_kwargs = kwargs.is_a?(Hash) ? kwargs : {}
66
+
67
+ # Create the payload as a tuple of (args_tuple, kwargs_dict)
68
+ payload = Pickle.dumps([python_args, python_kwargs])
69
+ args_blob_id = nil
70
+
71
+ if payload.bytesize > MAX_OBJECT_SIZE_BYTES
72
+ args_blob_id = blob_upload(payload)
73
+ end
74
+
75
+ Modal::Client::FunctionInput.new(
76
+ args: args_blob_id ? nil : payload,
77
+ args_blob_id: args_blob_id,
78
+ data_format: Modal::Client::DataFormat::DATA_FORMAT_PICKLE,
79
+ method_name: @method_name,
80
+ final_input: false
81
+ )
82
+ end
83
+
84
+ def blob_upload(data)
85
+ content_md5 = Digest::MD5.base64digest(data)
86
+ content_sha256 = Digest::SHA256.base64digest(data)
87
+ content_length = data.bytesize
88
+
89
+ request = Modal::Client::BlobCreateRequest.new(
90
+ content_md5: content_md5,
91
+ content_sha256_base64: content_sha256,
92
+ content_length: content_length
93
+ )
94
+ resp = Modal.client.call(:blob_create, request)
95
+
96
+ if resp.multipart
97
+ raise "Function input size exceeds multipart upload threshold, unsupported by this SDK version"
98
+ elsif resp.upload_url
99
+ require 'net/http'
100
+ require 'uri'
101
+
102
+ uri = URI.parse(resp.upload_url)
103
+ http = Net::HTTP.new(uri.host, uri.port)
104
+ http.use_ssl = uri.scheme == 'https'
105
+
106
+ req = Net::HTTP::Put.new(uri.request_uri)
107
+ req['Content-Type'] = 'application/octet-stream'
108
+ req['Content-MD5'] = content_md5
109
+ req.body = data
110
+
111
+ upload_resp = http.request(req)
112
+
113
+ unless upload_resp.code.to_i >= 200 && upload_resp.code.to_i < 300
114
+ raise "Failed blob upload: #{upload_resp.message}"
115
+ end
116
+ resp.blob_id
117
+ else
118
+ raise "Missing upload URL in BlobCreate response"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,30 @@
1
+ require_relative 'invocation'
2
+
3
+ module Modal
4
+ class FunctionCall
5
+ attr_reader :function_call_id
6
+
7
+ def initialize(function_call_id)
8
+ @function_call_id = function_call_id
9
+ end
10
+
11
+ def self.from_id(function_call_id)
12
+ new(function_call_id)
13
+ end
14
+
15
+ def get(options = {})
16
+ timeout = options[:timeout]
17
+ invocation = ControlPlaneInvocation.from_function_call_id(@function_call_id)
18
+ invocation.await_output(timeout)
19
+ end
20
+
21
+ def cancel(options = {})
22
+ terminate_containers = options[:terminate_containers] || false
23
+ request = Modal::Client::FunctionCallCancelRequest.new(
24
+ function_call_id: @function_call_id,
25
+ terminate_containers: terminate_containers
26
+ )
27
+ Modal.client.call(:function_call_cancel, request)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,75 @@
1
+ module Modal
2
+ class Image
3
+ attr_reader :image_id
4
+
5
+ def initialize(image_id)
6
+ @image_id = image_id
7
+ end
8
+
9
+ def self.from_registry_internal(app_id, tag, image_registry_config = nil)
10
+ request = Modal::Client::ImageGetOrCreateRequest.new(
11
+ app_id: app_id,
12
+ image: Modal::Client::Image.new(
13
+ dockerfile_commands: ["FROM #{tag}"],
14
+ image_registry_config: image_registry_config
15
+ ),
16
+ namespace: Modal::Client::DeploymentNamespace::DEPLOYMENT_NAMESPACE_WORKSPACE,
17
+ builder_version: Config.image_builder_version
18
+ )
19
+
20
+ resp = Modal.client.call(:image_get_or_create, request)
21
+ result = resp.result
22
+ metadata = resp.metadata
23
+
24
+ if result && result.status != :GENERIC_STATUS_UNSPECIFIED
25
+ metadata = resp.metadata
26
+ else
27
+ last_entry_id = ""
28
+
29
+ loop do
30
+ streaming_request = Modal::Client::ImageJoinStreamingRequest.new(
31
+ image_id: resp.image_id,
32
+ timeout: 55,
33
+ last_entry_id: last_entry_id
34
+ )
35
+
36
+ puts "Waiting for image build for #{resp.image_id}..."
37
+
38
+ begin
39
+ stream_resp = Modal.client.call(:image_join_streaming, streaming_request)
40
+
41
+ if stream_resp.result && stream_resp.result.status != Modal::Client::GenericResult::GenericStatus::GENERIC_STATUS_UNSPECIFIED
42
+ result = stream_resp.result
43
+ metadata = stream_resp.metadata
44
+ break
45
+ end
46
+
47
+ if stream_resp.entry_id && !stream_resp.entry_id.empty?
48
+ last_entry_id = stream_resp.entry_id
49
+ end
50
+
51
+ rescue => e
52
+ puts "Error checking build status: #{e.message}"
53
+ sleep(5)
54
+ next
55
+ end
56
+
57
+ sleep(2) # Wait before next check
58
+ end
59
+ end
60
+
61
+ case result.status
62
+ when :GENERIC_STATUS_FAILURE
63
+ raise "Image build for #{resp.image_id} failed with the exception:\n#{result.exception}"
64
+ when :GENERIC_STATUS_TERMINATED
65
+ raise "Image build for #{resp.image_id} terminated due to external shut-down. Please try again."
66
+ when :GENERIC_STATUS_TIMEOUT
67
+ raise "Image build for #{resp.image_id} timed out. Please try again with a larger timeout parameter."
68
+ when :GENERIC_STATUS_SUCCESS
69
+ new(resp.image_id)
70
+ else
71
+ raise "Image build for #{resp.image_id} failed with unknown status: #{result.status}"
72
+ end
73
+ end
74
+ end
75
+ end