loams 0.1.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.
@@ -0,0 +1,512 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "loams/wire"
5
+
6
+ module Loams
7
+ REASONS = [
8
+ :approval_expired,
9
+ :approval_already_decided,
10
+ :approval_stale_revision,
11
+ :requester_cannot_approve,
12
+ :decision_proof_invalid,
13
+ :step_up_required,
14
+ :reason_required,
15
+ :invalid_decision,
16
+ :pairing_expired,
17
+ :pairing_used,
18
+ :device_revoked,
19
+ :push_target_unknown,
20
+ :not_implemented,
21
+ :feature_not_in_variant,
22
+ :house_not_configured,
23
+ :house_syntax_error,
24
+ :house_unknown_table,
25
+ :house_unknown_database,
26
+ :house_access_denied,
27
+ :house_read_only,
28
+ :house_timeout,
29
+ :house_memory_limit,
30
+ :house_too_many_queries,
31
+ :house_quota_exceeded,
32
+ :house_disabled_function,
33
+ :house_cancelled,
34
+ :house_query_not_found,
35
+ :house_query_failed,
36
+ :project_not_found,
37
+ :branch_has_children,
38
+ :branch_protected,
39
+ :lsn_out_of_retention,
40
+ :endpoint_exists_for_branch,
41
+ :compute_start_failed,
42
+ :quota_exceeded,
43
+ :storage_unavailable,
44
+ :secret_already_issued,
45
+ :repo_not_found,
46
+ :repo_already_exists,
47
+ :repo_deleting,
48
+ :ref_stale,
49
+ :ref_protected,
50
+ :out_of_scope,
51
+ :object_format_unsupported,
52
+ :fork_too_deep,
53
+ :sequencer_unavailable,
54
+ :idempotency_key_reused,
55
+ :workspace_not_found,
56
+ :workspace_fenced,
57
+ :workspace_read_only,
58
+ :workspace_name_taken,
59
+ :object_missing,
60
+ :secret_detected,
61
+ :env_unsealed,
62
+ :env_access_denied,
63
+ :env_foreign_reference,
64
+ :live_bad_cursor,
65
+ :live_worker_crashed,
66
+ :live_deploy_conflict,
67
+ :live_schema_violation,
68
+ :live_index_not_ready,
69
+ :live_app_disabled,
70
+ :live_app_exists,
71
+ :live_function_error,
72
+ :live_function_timeout,
73
+ :live_function_out_of_memory,
74
+ :live_limit_exceeded,
75
+ :live_app_mismatch,
76
+ :live_origin_not_allowed,
77
+ :session_rotate,
78
+ :session_gone,
79
+ :node_draining,
80
+ :role_required,
81
+ :setup_required,
82
+ :token_missing,
83
+ :token_malformed,
84
+ :token_revoked,
85
+ :token_not_yet_valid,
86
+ :token_bad_signature,
87
+ :token_wrong_audience,
88
+ :token_wrong_issuer,
89
+ :token_unknown_key,
90
+ :token_algorithm_not_allowed,
91
+ :api_key_invalid,
92
+ :credential_not_accepted,
93
+ :credential_in_query_string,
94
+ :csrf_token_invalid,
95
+ :principal_not_allowed,
96
+ :cors_origin_not_allowed,
97
+ :auth_unavailable,
98
+ :invalid_argument,
99
+ :not_found,
100
+ :already_exists,
101
+ :permission_denied,
102
+ :token_expired,
103
+ :unauthenticated,
104
+ :failed_precondition,
105
+ :resource_exhausted,
106
+ :unavailable,
107
+ :deadline_exceeded,
108
+ :aborted,
109
+ :internal,
110
+ ].freeze
111
+
112
+ REASON_CODES = {
113
+ approval_expired: :failed_precondition,
114
+ approval_already_decided: :failed_precondition,
115
+ approval_stale_revision: :failed_precondition,
116
+ requester_cannot_approve: :permission_denied,
117
+ decision_proof_invalid: :permission_denied,
118
+ step_up_required: :unauthenticated,
119
+ reason_required: :invalid_argument,
120
+ invalid_decision: :invalid_argument,
121
+ pairing_expired: :failed_precondition,
122
+ pairing_used: :failed_precondition,
123
+ device_revoked: :unauthenticated,
124
+ push_target_unknown: :not_found,
125
+ not_implemented: :unimplemented,
126
+ feature_not_in_variant: :unimplemented,
127
+ house_not_configured: :unimplemented,
128
+ house_syntax_error: :invalid_argument,
129
+ house_unknown_table: :not_found,
130
+ house_unknown_database: :not_found,
131
+ house_access_denied: :permission_denied,
132
+ house_read_only: :failed_precondition,
133
+ house_timeout: :deadline_exceeded,
134
+ house_memory_limit: :resource_exhausted,
135
+ house_too_many_queries: :resource_exhausted,
136
+ house_quota_exceeded: :resource_exhausted,
137
+ house_disabled_function: :permission_denied,
138
+ house_cancelled: :canceled,
139
+ house_query_not_found: :not_found,
140
+ house_query_failed: :unknown,
141
+ project_not_found: :not_found,
142
+ branch_has_children: :failed_precondition,
143
+ branch_protected: :failed_precondition,
144
+ lsn_out_of_retention: :failed_precondition,
145
+ endpoint_exists_for_branch: :already_exists,
146
+ compute_start_failed: :unavailable,
147
+ quota_exceeded: :resource_exhausted,
148
+ storage_unavailable: :unavailable,
149
+ secret_already_issued: :failed_precondition,
150
+ repo_not_found: :not_found,
151
+ repo_already_exists: :already_exists,
152
+ repo_deleting: :failed_precondition,
153
+ ref_stale: :aborted,
154
+ ref_protected: :failed_precondition,
155
+ out_of_scope: :permission_denied,
156
+ object_format_unsupported: :unimplemented,
157
+ fork_too_deep: :failed_precondition,
158
+ sequencer_unavailable: :unavailable,
159
+ idempotency_key_reused: :failed_precondition,
160
+ workspace_not_found: :not_found,
161
+ workspace_fenced: :aborted,
162
+ workspace_read_only: :failed_precondition,
163
+ workspace_name_taken: :already_exists,
164
+ object_missing: :failed_precondition,
165
+ secret_detected: :failed_precondition,
166
+ env_unsealed: :invalid_argument,
167
+ env_access_denied: :permission_denied,
168
+ env_foreign_reference: :permission_denied,
169
+ live_bad_cursor: :invalid_argument,
170
+ live_worker_crashed: :internal,
171
+ live_deploy_conflict: :failed_precondition,
172
+ live_schema_violation: :invalid_argument,
173
+ live_index_not_ready: :failed_precondition,
174
+ live_app_disabled: :failed_precondition,
175
+ live_app_exists: :failed_precondition,
176
+ live_function_error: :unknown,
177
+ live_function_timeout: :deadline_exceeded,
178
+ live_function_out_of_memory: :resource_exhausted,
179
+ live_limit_exceeded: :resource_exhausted,
180
+ live_app_mismatch: :permission_denied,
181
+ live_origin_not_allowed: :permission_denied,
182
+ session_rotate: :unavailable,
183
+ session_gone: :not_found,
184
+ node_draining: :unavailable,
185
+ role_required: :permission_denied,
186
+ setup_required: :failed_precondition,
187
+ token_missing: :unauthenticated,
188
+ token_malformed: :unauthenticated,
189
+ token_revoked: :unauthenticated,
190
+ token_not_yet_valid: :unauthenticated,
191
+ token_bad_signature: :unauthenticated,
192
+ token_wrong_audience: :unauthenticated,
193
+ token_wrong_issuer: :unauthenticated,
194
+ token_unknown_key: :unauthenticated,
195
+ token_algorithm_not_allowed: :unauthenticated,
196
+ api_key_invalid: :unauthenticated,
197
+ credential_not_accepted: :unauthenticated,
198
+ credential_in_query_string: :unauthenticated,
199
+ csrf_token_invalid: :permission_denied,
200
+ principal_not_allowed: :permission_denied,
201
+ cors_origin_not_allowed: :permission_denied,
202
+ auth_unavailable: :unavailable,
203
+ invalid_argument: :invalid_argument,
204
+ not_found: :not_found,
205
+ already_exists: :already_exists,
206
+ permission_denied: :permission_denied,
207
+ token_expired: :unauthenticated,
208
+ unauthenticated: :unauthenticated,
209
+ failed_precondition: :failed_precondition,
210
+ resource_exhausted: :resource_exhausted,
211
+ unavailable: :unavailable,
212
+ deadline_exceeded: :deadline_exceeded,
213
+ aborted: :aborted,
214
+ internal: :internal,
215
+ }.freeze
216
+
217
+ HTTP_TO_CODE = {
218
+ 400 => :invalid_argument,
219
+ 401 => :unauthenticated,
220
+ 403 => :permission_denied,
221
+ 404 => :not_found,
222
+ 409 => :already_exists,
223
+ 429 => :resource_exhausted,
224
+ 499 => :canceled,
225
+ 500 => :internal,
226
+ 501 => :unimplemented,
227
+ 502 => :unavailable,
228
+ 503 => :unavailable,
229
+ 504 => :deadline_exceeded,
230
+ }.freeze
231
+
232
+ GRPC_TO_CODE = {
233
+ 1 => :canceled,
234
+ 2 => :unknown,
235
+ 3 => :invalid_argument,
236
+ 4 => :deadline_exceeded,
237
+ 5 => :not_found,
238
+ 6 => :already_exists,
239
+ 7 => :permission_denied,
240
+ 8 => :resource_exhausted,
241
+ 9 => :failed_precondition,
242
+ 10 => :aborted,
243
+ 11 => :out_of_range,
244
+ 12 => :unimplemented,
245
+ 13 => :internal,
246
+ 14 => :unavailable,
247
+ 15 => :data_loss,
248
+ 16 => :unauthenticated,
249
+ }.freeze
250
+
251
+ CODE_TO_HTTP = {
252
+ canceled: 499,
253
+ unknown: 500,
254
+ invalid_argument: 400,
255
+ deadline_exceeded: 504,
256
+ not_found: 404,
257
+ already_exists: 409,
258
+ permission_denied: 403,
259
+ resource_exhausted: 429,
260
+ failed_precondition: 400,
261
+ aborted: 409,
262
+ out_of_range: 400,
263
+ unimplemented: 501,
264
+ internal: 500,
265
+ unavailable: 503,
266
+ data_loss: 500,
267
+ unauthenticated: 401,
268
+ }.freeze
269
+
270
+ # ErrorInfo struct representing decoded detail
271
+ ErrorInfo = Struct.new(:reason, :domain, :metadata, :hint, keyword_init: true)
272
+
273
+ class LoamsError < StandardError
274
+ attr_reader :code, :reason, :unknown_reason, :http_status, :metadata, :hint, :details, :rpc
275
+
276
+ def initialize(
277
+ message = nil,
278
+ code: :unknown,
279
+ reason: nil,
280
+ unknown_reason: nil,
281
+ http_status: nil,
282
+ metadata: {},
283
+ hint: "",
284
+ details: nil,
285
+ rpc: nil
286
+ )
287
+ @code = code.to_sym
288
+ @reason = reason ? reason.to_sym : nil
289
+ @unknown_reason = unknown_reason
290
+ @http_status = http_status || Loams::CODE_TO_HTTP[@code]
291
+ @metadata = metadata || {}
292
+ @hint = hint || ""
293
+ @details = details
294
+ @rpc = rpc
295
+
296
+ msg = message || default_message
297
+ super(msg)
298
+ end
299
+
300
+ def default_message
301
+ parts = ["[#{@code}]"]
302
+ parts << "reason=#{@reason || @unknown_reason}" if @reason || @unknown_reason
303
+ parts << "rpc=#{@rpc}" if @rpc
304
+ parts.join(" ")
305
+ end
306
+
307
+ def retryable?
308
+ [429, 502, 503, 504].include?(@http_status) || [:unavailable, :resource_exhausted].include?(@code)
309
+ end
310
+ end
311
+
312
+ class CanceledError < LoamsError; end
313
+ class UnknownError < LoamsError; end
314
+ class InvalidArgumentError < LoamsError; end
315
+ class DeadlineExceededError < LoamsError; end
316
+ class NotFoundError < LoamsError; end
317
+ class AlreadyExistsError < LoamsError; end
318
+ class PermissionDeniedError < LoamsError; end
319
+ class ResourceExhaustedError < LoamsError; end
320
+ class FailedPreconditionError < LoamsError; end
321
+ class AbortedError < LoamsError; end
322
+ class OutOfRangeError < LoamsError; end
323
+ class UnimplementedError < LoamsError; end
324
+ class InternalError < LoamsError; end
325
+ class UnavailableError < LoamsError; end
326
+ class DataLossError < LoamsError; end
327
+ class UnauthenticatedError < LoamsError; end
328
+
329
+ # Specialised subclasses for distinct reason handling
330
+ class TokenExpiredError < UnauthenticatedError; end
331
+ class FeatureNotInVariantError < UnimplementedError; end
332
+
333
+ CODE_TO_CLASS = {
334
+ canceled: CanceledError,
335
+ unknown: UnknownError,
336
+ invalid_argument: InvalidArgumentError,
337
+ deadline_exceeded: DeadlineExceededError,
338
+ not_found: NotFoundError,
339
+ already_exists: AlreadyExistsError,
340
+ permission_denied: PermissionDeniedError,
341
+ resource_exhausted: ResourceExhaustedError,
342
+ failed_precondition: FailedPreconditionError,
343
+ aborted: AbortedError,
344
+ out_of_range: OutOfRangeError,
345
+ unimplemented: UnimplementedError,
346
+ internal: InternalError,
347
+ unavailable: UnavailableError,
348
+ data_loss: DataLossError,
349
+ unauthenticated: UnauthenticatedError,
350
+ }.freeze
351
+
352
+ module Base64Helper
353
+ def self.decode(str)
354
+ return "" if str.nil? || str.empty?
355
+ clean = str.tr("-_", "+/").gsub(/\s+/, "")
356
+ padded = clean + ("=" * ((4 - (clean.length % 4)) % 4))
357
+ padded.unpack1("m0") rescue nil
358
+ end
359
+
360
+ def self.encode(bytes)
361
+ return "" if bytes.nil? || bytes.empty?
362
+ [bytes].pack("m0").strip
363
+ end
364
+ end
365
+
366
+ module ErrorParser
367
+ # Decodes an ErrorInfo protobuf from raw bytes (either raw or wrapped in Status)
368
+ def self.decode_error_info_bytes(bytes)
369
+ return nil if bytes.nil? || bytes.empty?
370
+
371
+ begin
372
+ # Check if wrapped in google.rpc.Status (field 3 repeated Any)
373
+ Loams::Wire.each_field(bytes) do |field, wt, val|
374
+ if field == 3 && wt == 2
375
+ type_url = Loams::Wire.find_bytes(val, 1)
376
+ payload = Loams::Wire.find_bytes(val, 2)
377
+ if payload && (type_url.nil? || type_url.include?("ErrorInfo"))
378
+ return parse_raw_error_info(payload)
379
+ end
380
+ end
381
+ end
382
+
383
+ # Fallback: raw ErrorInfo
384
+ parse_raw_error_info(bytes)
385
+ rescue StandardError
386
+ nil
387
+ end
388
+ end
389
+
390
+ def self.parse_raw_error_info(payload)
391
+ reason = Loams::Wire.find_bytes(payload, 1)
392
+ domain = Loams::Wire.find_bytes(payload, 2)
393
+ metadata = {}
394
+ # field 3: map<string, string> -> message with field 1 key, field 2 value
395
+ Loams::Wire.each_field(payload) do |f, wt, val|
396
+ if f == 3 && wt == 2
397
+ k = Loams::Wire.find_bytes(val, 1)
398
+ v = Loams::Wire.find_bytes(val, 2)
399
+ metadata[k] = v if k
400
+ end
401
+ end
402
+ # hint might be field 4 or similar if defined
403
+ hint = Loams::Wire.find_bytes(payload, 4) || ""
404
+
405
+ return nil if reason.nil? && domain.nil?
406
+
407
+ ErrorInfo.new(reason: reason, domain: domain, metadata: metadata, hint: hint)
408
+ end
409
+
410
+ # Parses a Connect JSON error body or trailers
411
+ def self.parse_connect_error(body, status = nil, rpc = nil)
412
+ parsed = JSON.parse(body) rescue {}
413
+ parsed = parsed["error"] if parsed["error"].is_a?(Hash)
414
+
415
+ code_str = parsed["code"]
416
+ message = parsed["message"] || "Connect error"
417
+
418
+ code = code_str ? code_str.to_sym : (status ? HTTP_TO_CODE[status] || :unknown : :unknown)
419
+
420
+ error_info = nil
421
+ if parsed["details"].is_a?(Array)
422
+ parsed["details"].each do |d|
423
+ next unless d.is_a?(Hash)
424
+
425
+ if d["debug"].is_a?(Hash) && d["debug"]["reason"]
426
+ error_info = ErrorInfo.new(
427
+ reason: d["debug"]["reason"],
428
+ domain: d["debug"]["domain"],
429
+ metadata: d["debug"]["metadata"] || {},
430
+ hint: d["debug"]["hint"] || "",
431
+ )
432
+ break
433
+ elsif d["value"].is_a?(String)
434
+ bytes = Base64Helper.decode(d["value"])
435
+ if bytes
436
+ info = decode_error_info_bytes(bytes)
437
+ if info
438
+ error_info = info
439
+ break
440
+ end
441
+ end
442
+ elsif d["reason"].is_a?(String)
443
+ error_info = ErrorInfo.new(
444
+ reason: d["reason"],
445
+ domain: d["domain"],
446
+ metadata: d["metadata"] || {},
447
+ hint: d["hint"] || "",
448
+ )
449
+ break
450
+ end
451
+ end
452
+ end
453
+
454
+ build_error(code, message, error_info, status, rpc, parsed["details"])
455
+ end
456
+
457
+ # Parses gRPC-Web trailers
458
+ def self.parse_grpc_trailers(headers, status = nil, rpc = nil)
459
+ grpc_status = headers["grpc-status"] || headers["Grpc-Status"]
460
+ grpc_message = headers["grpc-message"] || headers["Grpc-Message"]
461
+ status_bin = headers["grpc-status-details-bin"] || headers["Grpc-Status-Details-Bin"]
462
+
463
+ code_num = grpc_status ? grpc_status.to_i : 2
464
+ code = GRPC_TO_CODE[code_num] || :unknown
465
+ message = grpc_message ? URI.decode_www_form_component(grpc_message) : "gRPC error #{code_num}"
466
+
467
+ error_info = nil
468
+ if status_bin
469
+ bytes = Base64Helper.decode(status_bin)
470
+ error_info = decode_error_info_bytes(bytes) if bytes
471
+ end
472
+
473
+ build_error(code, message, error_info, status, rpc)
474
+ end
475
+
476
+ def self.build_error(code, message, error_info, status, rpc, details = nil)
477
+ raw_reason = error_info&.reason
478
+ reason_sym = nil
479
+ unknown_reason = nil
480
+
481
+ if raw_reason
482
+ sym = raw_reason.to_sym
483
+ if REASONS.include?(sym)
484
+ reason_sym = sym
485
+ else
486
+ unknown_reason = raw_reason
487
+ end
488
+ end
489
+
490
+ # Pick specific subclass if applicable
491
+ klass = if reason_sym == :token_expired
492
+ TokenExpiredError
493
+ elsif reason_sym == :feature_not_in_variant
494
+ FeatureNotInVariantError
495
+ else
496
+ CODE_TO_CLASS[code] || LoamsError
497
+ end
498
+
499
+ klass.new(
500
+ message,
501
+ code: code,
502
+ reason: reason_sym,
503
+ unknown_reason: unknown_reason,
504
+ http_status: status,
505
+ metadata: error_info&.metadata || {},
506
+ hint: error_info&.hint || "",
507
+ details: details,
508
+ rpc: rpc,
509
+ )
510
+ end
511
+ end
512
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Loams
6
+ module Idempotency
7
+ # Mints a standard UUIDv7
8
+ def self.uuidv7
9
+ now_ms = (Time.now.to_f * 1000).to_i
10
+ rand_bytes = SecureRandom.random_bytes(10)
11
+
12
+ time_hi = (now_ms >> 16) & 0xFFFFFFFF
13
+ time_low = now_ms & 0xFFFF
14
+
15
+ r1, r2, r3, r4, r5, r6, r7, r8, r9, r10 = rand_bytes.bytes
16
+ ver_and_rand = 0x7000 | ((r1 & 0x0F) << 8) | r2
17
+ var_and_rand = 0x80 | (r3 & 0x3F)
18
+
19
+ format(
20
+ "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x",
21
+ time_hi,
22
+ time_low,
23
+ ver_and_rand,
24
+ var_and_rand,
25
+ r4,
26
+ r5, r6, r7, r8, r9, r10
27
+ )
28
+ end
29
+
30
+ # Applies an idempotency key to a request message if the binding expects one
31
+ def self.apply(binding, request, explicit_key = nil, mint: true)
32
+ return nil unless binding.takes_idempotency_key?
33
+
34
+ existing_key = if request.respond_to?(:idempotency_key)
35
+ request.idempotency_key
36
+ elsif request.is_a?(Hash)
37
+ request[:idempotency_key] || request["idempotency_key"] || request["idempotencyKey"]
38
+ end
39
+
40
+ if explicit_key && !explicit_key.empty?
41
+ set_key(request, explicit_key)
42
+ explicit_key
43
+ elsif existing_key && !existing_key.empty?
44
+ existing_key
45
+ elsif mint
46
+ key = uuidv7
47
+ set_key(request, key)
48
+ key
49
+ else
50
+ nil
51
+ end
52
+ end
53
+
54
+ def self.set_key(request, key)
55
+ if request.respond_to?(:idempotency_key=)
56
+ request.idempotency_key = key
57
+ elsif request.is_a?(Hash)
58
+ request[:idempotency_key] = key
59
+ end
60
+ end
61
+ end
62
+ end