kubemq 1.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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/LICENSE +201 -0
  4. data/README.md +237 -0
  5. data/lib/kubemq/base_client.rb +180 -0
  6. data/lib/kubemq/cancellation_token.rb +63 -0
  7. data/lib/kubemq/channel_info.rb +84 -0
  8. data/lib/kubemq/configuration.rb +247 -0
  9. data/lib/kubemq/cq/client.rb +446 -0
  10. data/lib/kubemq/cq/command_message.rb +59 -0
  11. data/lib/kubemq/cq/command_received.rb +52 -0
  12. data/lib/kubemq/cq/command_response.rb +44 -0
  13. data/lib/kubemq/cq/command_response_message.rb +58 -0
  14. data/lib/kubemq/cq/commands_subscription.rb +45 -0
  15. data/lib/kubemq/cq/queries_subscription.rb +45 -0
  16. data/lib/kubemq/cq/query_message.rb +70 -0
  17. data/lib/kubemq/cq/query_received.rb +52 -0
  18. data/lib/kubemq/cq/query_response.rb +59 -0
  19. data/lib/kubemq/cq/query_response_message.rb +67 -0
  20. data/lib/kubemq/error_codes.rb +181 -0
  21. data/lib/kubemq/errors/error_mapper.rb +134 -0
  22. data/lib/kubemq/errors.rb +276 -0
  23. data/lib/kubemq/interceptors/auth_interceptor.rb +78 -0
  24. data/lib/kubemq/interceptors/error_mapping_interceptor.rb +75 -0
  25. data/lib/kubemq/interceptors/metrics_interceptor.rb +95 -0
  26. data/lib/kubemq/interceptors/retry_interceptor.rb +119 -0
  27. data/lib/kubemq/proto/kubemq_pb.rb +43 -0
  28. data/lib/kubemq/proto/kubemq_services_pb.rb +35 -0
  29. data/lib/kubemq/pubsub/client.rb +475 -0
  30. data/lib/kubemq/pubsub/event_message.rb +52 -0
  31. data/lib/kubemq/pubsub/event_received.rb +48 -0
  32. data/lib/kubemq/pubsub/event_send_result.rb +31 -0
  33. data/lib/kubemq/pubsub/event_sender.rb +112 -0
  34. data/lib/kubemq/pubsub/event_store_message.rb +53 -0
  35. data/lib/kubemq/pubsub/event_store_received.rb +47 -0
  36. data/lib/kubemq/pubsub/event_store_result.rb +33 -0
  37. data/lib/kubemq/pubsub/event_store_sender.rb +164 -0
  38. data/lib/kubemq/pubsub/events_store_subscription.rb +81 -0
  39. data/lib/kubemq/pubsub/events_subscription.rb +43 -0
  40. data/lib/kubemq/queues/client.rb +366 -0
  41. data/lib/kubemq/queues/downstream_receiver.rb +247 -0
  42. data/lib/kubemq/queues/queue_message.rb +99 -0
  43. data/lib/kubemq/queues/queue_message_received.rb +148 -0
  44. data/lib/kubemq/queues/queue_poll_request.rb +77 -0
  45. data/lib/kubemq/queues/queue_poll_response.rb +138 -0
  46. data/lib/kubemq/queues/queue_send_result.rb +49 -0
  47. data/lib/kubemq/queues/upstream_sender.rb +180 -0
  48. data/lib/kubemq/server_info.rb +57 -0
  49. data/lib/kubemq/subscription.rb +98 -0
  50. data/lib/kubemq/telemetry/otel.rb +64 -0
  51. data/lib/kubemq/telemetry/semconv.rb +51 -0
  52. data/lib/kubemq/transport/channel_manager.rb +212 -0
  53. data/lib/kubemq/transport/converter.rb +287 -0
  54. data/lib/kubemq/transport/grpc_transport.rb +411 -0
  55. data/lib/kubemq/transport/message_buffer.rb +105 -0
  56. data/lib/kubemq/transport/reconnect_manager.rb +111 -0
  57. data/lib/kubemq/transport/state_machine.rb +150 -0
  58. data/lib/kubemq/types.rb +80 -0
  59. data/lib/kubemq/validation/validator.rb +216 -0
  60. data/lib/kubemq/version.rb +6 -0
  61. data/lib/kubemq.rb +118 -0
  62. metadata +138 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ module CQ
5
+ # Configuration for subscribing to command requests.
6
+ #
7
+ # Pass to {CQClient#subscribe_to_commands} along with a block to
8
+ # receive {CommandReceived} messages. Use {#group} for load-balanced
9
+ # delivery across multiple responders.
10
+ #
11
+ # @example
12
+ # sub = KubeMQ::CQ::CommandsSubscription.new(
13
+ # channel: "commands.user.create",
14
+ # group: "user-service"
15
+ # )
16
+ # client.subscribe_to_commands(sub) do |cmd|
17
+ # # process and respond
18
+ # end
19
+ #
20
+ # @see CQClient#subscribe_to_commands
21
+ # @see CommandReceived
22
+ class CommandsSubscription
23
+ # @!attribute [rw] channel
24
+ # @return [String] channel name to subscribe to
25
+ # @!attribute [rw] group
26
+ # @return [String, nil] consumer group for load-balanced delivery
27
+ attr_accessor :channel, :group
28
+
29
+ # @param channel [String] channel name (required)
30
+ # @param group [String, nil] consumer group name (default: +nil+)
31
+ def initialize(channel:, group: nil)
32
+ @channel = channel
33
+ @group = group
34
+ end
35
+
36
+ # Returns the subscribe type constant for the transport layer.
37
+ #
38
+ # @return [Integer] {SubscribeType::COMMANDS}
39
+ # @api private
40
+ def subscribe_type
41
+ SubscribeType::COMMANDS
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ module CQ
5
+ # Configuration for subscribing to query requests.
6
+ #
7
+ # Pass to {CQClient#subscribe_to_queries} along with a block to
8
+ # receive {QueryReceived} messages. Use {#group} for load-balanced
9
+ # delivery across multiple responders.
10
+ #
11
+ # @example
12
+ # sub = KubeMQ::CQ::QueriesSubscription.new(
13
+ # channel: "queries.user.get",
14
+ # group: "user-service"
15
+ # )
16
+ # client.subscribe_to_queries(sub) do |query|
17
+ # # process and respond
18
+ # end
19
+ #
20
+ # @see CQClient#subscribe_to_queries
21
+ # @see QueryReceived
22
+ class QueriesSubscription
23
+ # @!attribute [rw] channel
24
+ # @return [String] channel name to subscribe to
25
+ # @!attribute [rw] group
26
+ # @return [String, nil] consumer group for load-balanced delivery
27
+ attr_accessor :channel, :group
28
+
29
+ # @param channel [String] channel name (required)
30
+ # @param group [String, nil] consumer group name (default: +nil+)
31
+ def initialize(channel:, group: nil)
32
+ @channel = channel
33
+ @group = group
34
+ end
35
+
36
+ # Returns the subscribe type constant for the transport layer.
37
+ #
38
+ # @return [Integer] {SubscribeType::QUERIES}
39
+ # @api private
40
+ def subscribe_type
41
+ SubscribeType::QUERIES
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module KubeMQ
6
+ module CQ
7
+ # Outbound query message for the request/reply (with data) pattern.
8
+ #
9
+ # Construct a +QueryMessage+ and pass it to {CQClient#send_query}.
10
+ # The broker forwards the query to a subscriber and returns a
11
+ # {QueryResponse} containing the response data. Optionally set
12
+ # {#cache_key} and {#cache_ttl} for server-side response caching.
13
+ #
14
+ # @example
15
+ # query = KubeMQ::CQ::QueryMessage.new(
16
+ # channel: "queries.user.get",
17
+ # timeout: 10_000,
18
+ # metadata: "get-user",
19
+ # body: '{"user_id": 42}',
20
+ # cache_key: "user:42",
21
+ # cache_ttl: 60
22
+ # )
23
+ # response = client.send_query(query)
24
+ # puts "Result: #{response.body}, cached: #{response.cache_hit}"
25
+ #
26
+ # @see CQClient#send_query
27
+ # @see QueryResponse
28
+ class QueryMessage
29
+ # @!attribute [rw] id
30
+ # @return [String] unique message identifier (auto-generated UUID if not provided)
31
+ # @!attribute [rw] channel
32
+ # @return [String] target channel name
33
+ # @!attribute [rw] metadata
34
+ # @return [String, nil] arbitrary metadata string
35
+ # @!attribute [rw] body
36
+ # @return [String, nil] message payload (binary-safe)
37
+ # @!attribute [rw] tags
38
+ # @return [Hash{String => String}] user-defined key-value tags
39
+ # @!attribute [rw] timeout
40
+ # @return [Integer] maximum time to wait for a response
41
+ # @note Timeout is in milliseconds
42
+ # @!attribute [rw] cache_key
43
+ # @return [String, nil] cache key for server-side response caching
44
+ # @!attribute [rw] cache_ttl
45
+ # @return [Integer, nil] cache TTL in seconds
46
+ attr_accessor :id, :channel, :metadata, :body, :tags, :timeout, :cache_key, :cache_ttl
47
+
48
+ # @param channel [String] target channel name (required)
49
+ # @param timeout [Integer] response timeout in milliseconds (required)
50
+ # @param metadata [String, nil] arbitrary metadata
51
+ # @param body [String, nil] message payload
52
+ # @param tags [Hash{String => String}, nil] key-value tags (default: +{}+)
53
+ # @param id [String, nil] message ID (default: auto-generated UUID)
54
+ # @param cache_key [String, nil] server-side cache key
55
+ # @param cache_ttl [Integer, nil] cache TTL in seconds
56
+ # @note +timeout+ is in milliseconds
57
+ def initialize(channel:, timeout:, metadata: nil, body: nil, tags: nil, id: nil,
58
+ cache_key: nil, cache_ttl: nil)
59
+ @id = id || SecureRandom.uuid
60
+ @channel = channel
61
+ @timeout = timeout
62
+ @metadata = metadata
63
+ @body = body
64
+ @tags = tags || {}
65
+ @cache_key = cache_key
66
+ @cache_ttl = cache_ttl
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ module CQ
5
+ # A query received by a subscriber via {CQClient#subscribe_to_queries}.
6
+ #
7
+ # After processing, use the {#reply_channel} and {#id} to construct a
8
+ # {QueryResponseMessage} and send it back via {CQClient#send_response}.
9
+ #
10
+ # @see CQClient#subscribe_to_queries
11
+ # @see QueryResponseMessage
12
+ # @see CQClient#send_response
13
+ class QueryReceived
14
+ # @!attribute [r] id
15
+ # @return [String] request identifier (use as +request_id+ in the response)
16
+ # @!attribute [r] channel
17
+ # @return [String] the channel the query was sent to
18
+ # @!attribute [r] metadata
19
+ # @return [String] query metadata
20
+ # @!attribute [r] body
21
+ # @return [String] query payload (binary)
22
+ # @!attribute [r] reply_channel
23
+ # @return [String] channel to send the response back on
24
+ # @!attribute [r] tags
25
+ # @return [Hash{String => String}] user-defined key-value tags
26
+ # @!attribute [r] timeout
27
+ # @return [Integer] original timeout from the sender (milliseconds)
28
+ # @!attribute [r] client_id
29
+ # @return [String, nil] the sender's client identifier
30
+ attr_reader :id, :channel, :metadata, :body, :reply_channel, :tags, :timeout, :client_id
31
+
32
+ # @param id [String] request identifier
33
+ # @param channel [String] source channel name
34
+ # @param metadata [String] query metadata
35
+ # @param body [String] query payload
36
+ # @param reply_channel [String] response channel
37
+ # @param tags [Hash{String => String}, nil] key-value tags
38
+ # @param timeout [Integer] sender timeout in milliseconds (default: +0+)
39
+ # @param client_id [String, nil] sender's client ID
40
+ def initialize(id:, channel:, metadata:, body:, reply_channel:, tags:, timeout: 0, client_id: nil, **_)
41
+ @id = id
42
+ @channel = channel
43
+ @metadata = metadata
44
+ @body = body
45
+ @reply_channel = reply_channel
46
+ @tags = tags || {}
47
+ @timeout = timeout
48
+ @client_id = client_id
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ module CQ
5
+ # Response returned by {CQClient#send_query} after a subscriber
6
+ # processes the query.
7
+ #
8
+ # Contains the response {#body} and {#metadata}, plus a {#cache_hit}
9
+ # flag indicating whether the response was served from the broker's
10
+ # server-side cache.
11
+ #
12
+ # @see CQClient#send_query
13
+ # @see QueryMessage
14
+ class QueryResponse
15
+ # @!attribute [r] client_id
16
+ # @return [String] the responder's client identifier
17
+ # @!attribute [r] request_id
18
+ # @return [String] the original query request identifier
19
+ # @!attribute [r] executed
20
+ # @return [Boolean] +true+ if the query was executed successfully
21
+ # @!attribute [r] error
22
+ # @return [String, nil] error description if execution failed
23
+ # @!attribute [r] timestamp
24
+ # @return [Integer] broker-assigned response timestamp (Unix nanoseconds)
25
+ # @!attribute [r] tags
26
+ # @return [Hash{String => String}] user-defined key-value tags
27
+ # @!attribute [r] body
28
+ # @return [String, nil] response payload
29
+ # @!attribute [r] metadata
30
+ # @return [String, nil] response metadata
31
+ # @!attribute [r] cache_hit
32
+ # @return [Boolean] +true+ if the response was served from cache
33
+ attr_reader :client_id, :request_id, :executed, :error, :timestamp,
34
+ :tags, :body, :metadata, :cache_hit
35
+
36
+ # @param client_id [String] responder's client ID
37
+ # @param request_id [String] original request ID
38
+ # @param executed [Boolean] whether the query was executed
39
+ # @param error [String, nil] error description on failure
40
+ # @param timestamp [Integer] response timestamp (default: +0+)
41
+ # @param tags [Hash{String => String}, nil] key-value tags
42
+ # @param body [String, nil] response payload
43
+ # @param metadata [String, nil] response metadata
44
+ # @param cache_hit [Boolean] whether served from cache (default: +false+)
45
+ def initialize(client_id:, request_id:, executed:, error: nil, timestamp: 0,
46
+ tags: nil, body: nil, metadata: nil, cache_hit: false)
47
+ @client_id = client_id
48
+ @request_id = request_id
49
+ @executed = executed
50
+ @error = error
51
+ @timestamp = timestamp
52
+ @tags = tags || {}
53
+ @body = body
54
+ @metadata = metadata
55
+ @cache_hit = cache_hit
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ module CQ
5
+ # Outbound response to a received query, sent via {CQClient#send_response}.
6
+ #
7
+ # Construct from the fields of a {QueryReceived} — copy +id+ to
8
+ # +request_id+ and +reply_channel+ — then set {#executed}, {#body},
9
+ # and optionally {#cache_hit}.
10
+ #
11
+ # @example Respond to a query
12
+ # response_msg = KubeMQ::CQ::QueryResponseMessage.new(
13
+ # request_id: received_query.id,
14
+ # reply_channel: received_query.reply_channel,
15
+ # executed: true,
16
+ # body: '{"name": "Alice", "age": 30}'
17
+ # )
18
+ # client.send_response(response_msg)
19
+ #
20
+ # @see CQClient#send_response
21
+ # @see QueryReceived
22
+ class QueryResponseMessage
23
+ # @!attribute [rw] request_id
24
+ # @return [String] the original query's request identifier
25
+ # @!attribute [rw] reply_channel
26
+ # @return [String] the channel to send the response on
27
+ # @!attribute [rw] client_id
28
+ # @return [String, nil] responder's client identifier
29
+ # @!attribute [rw] executed
30
+ # @return [Boolean] whether the query was executed successfully
31
+ # @!attribute [rw] error
32
+ # @return [String, nil] error description if execution failed
33
+ # @!attribute [rw] body
34
+ # @return [String, nil] response payload
35
+ # @!attribute [rw] metadata
36
+ # @return [String, nil] response metadata
37
+ # @!attribute [rw] tags
38
+ # @return [Hash{String => String}] user-defined key-value tags
39
+ # @!attribute [rw] cache_hit
40
+ # @return [Boolean] whether this response should be cached
41
+ attr_accessor :request_id, :reply_channel, :client_id, :executed,
42
+ :error, :body, :metadata, :tags, :cache_hit
43
+
44
+ # @param request_id [String] the original query's request ID (required)
45
+ # @param reply_channel [String] response channel from {QueryReceived#reply_channel} (required)
46
+ # @param executed [Boolean] whether the query was executed (required)
47
+ # @param error [String, nil] error description on failure
48
+ # @param body [String, nil] response payload
49
+ # @param metadata [String, nil] response metadata
50
+ # @param tags [Hash{String => String}, nil] key-value tags
51
+ # @param client_id [String, nil] responder's client ID
52
+ # @param cache_hit [Boolean] whether this response is from cache (default: +false+)
53
+ def initialize(request_id:, reply_channel:, executed:, error: nil, body: nil,
54
+ metadata: nil, tags: nil, client_id: nil, cache_hit: false)
55
+ @request_id = request_id
56
+ @reply_channel = reply_channel
57
+ @executed = executed
58
+ @error = error
59
+ @body = body
60
+ @metadata = metadata
61
+ @tags = tags || {}
62
+ @client_id = client_id
63
+ @cache_hit = cache_hit
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubeMQ
4
+ # Enumeration of SDK error codes used in {Error#code}.
5
+ #
6
+ # Each code maps to an {ErrorCategory} and retryable flag via {ERROR_CLASSIFICATION},
7
+ # and to an actionable suggestion via {ERROR_SUGGESTIONS}.
8
+ #
9
+ # @see Error
10
+ # @see ErrorCategory
11
+ # @see ERROR_CLASSIFICATION
12
+ module ErrorCode
13
+ # gRPC connection timed out or deadline exceeded.
14
+ CONNECTION_TIMEOUT = 'CONNECTION_TIMEOUT'
15
+ # Broker is unreachable — network, DNS, or firewall issue.
16
+ UNAVAILABLE = 'UNAVAILABLE'
17
+ # Transport exists but is not in the READY state.
18
+ CONNECTION_NOT_READY = 'CONNECTION_NOT_READY'
19
+ # Authentication token is invalid, expired, or missing.
20
+ AUTH_FAILED = 'AUTH_FAILED'
21
+ # Authenticated but lacking required permissions.
22
+ PERMISSION_DENIED = 'PERMISSION_DENIED'
23
+ # Request parameters failed validation.
24
+ VALIDATION_ERROR = 'VALIDATION_ERROR'
25
+ # Resource (channel, queue) already exists.
26
+ ALREADY_EXISTS = 'ALREADY_EXISTS'
27
+ # Sequence number or offset is out of valid range.
28
+ OUT_OF_RANGE = 'OUT_OF_RANGE'
29
+ # Requested channel or resource does not exist.
30
+ NOT_FOUND = 'NOT_FOUND'
31
+ # Broker rate limit or resource quota exceeded.
32
+ RESOURCE_EXHAUSTED = 'RESOURCE_EXHAUSTED'
33
+ # Operation aborted due to a conflict (e.g., transaction contention).
34
+ ABORTED = 'ABORTED'
35
+ # Internal broker error — check server logs.
36
+ INTERNAL = 'INTERNAL'
37
+ # Unknown error — check server logs.
38
+ UNKNOWN = 'UNKNOWN'
39
+ # Operation not supported by the broker version.
40
+ UNIMPLEMENTED = 'UNIMPLEMENTED'
41
+ # Unrecoverable data loss at the broker.
42
+ DATA_LOSS = 'DATA_LOSS'
43
+ # Operation was cooperatively cancelled via {CancellationToken}.
44
+ CANCELLED = 'CANCELLED'
45
+ # Reconnect message buffer is full; oldest messages dropped.
46
+ BUFFER_FULL = 'BUFFER_FULL'
47
+ # A bidirectional gRPC stream broke unexpectedly.
48
+ STREAM_BROKEN = 'STREAM_BROKEN'
49
+ # Client has been closed; create a new instance.
50
+ CLIENT_CLOSED = 'CLIENT_CLOSED'
51
+ # Invalid client configuration (address, TLS, etc.).
52
+ CONFIGURATION_ERROR = 'CONFIGURATION_ERROR'
53
+ # A user-provided callback raised an exception.
54
+ CALLBACK_ERROR = 'CALLBACK_ERROR'
55
+ end
56
+
57
+ # Categories for classifying {ErrorCode} values by failure domain.
58
+ #
59
+ # Used by {KubeMQ.classify_error} to determine error handling strategy.
60
+ #
61
+ # @see ERROR_CLASSIFICATION
62
+ # @see KubeMQ.classify_error
63
+ module ErrorCategory
64
+ # Temporary failure that may resolve on retry (e.g., network blip).
65
+ TRANSIENT = 'TRANSIENT'
66
+ # Operation exceeded its deadline.
67
+ TIMEOUT = 'TIMEOUT'
68
+ # Rate limit or resource quota exceeded.
69
+ THROTTLING = 'THROTTLING'
70
+ # Invalid or missing authentication credentials.
71
+ AUTHENTICATION = 'AUTHENTICATION'
72
+ # Valid credentials but insufficient permissions.
73
+ AUTHORIZATION = 'AUTHORIZATION'
74
+ # Invalid request parameters or preconditions not met.
75
+ VALIDATION = 'VALIDATION'
76
+ # Requested resource does not exist.
77
+ NOT_FOUND = 'NOT_FOUND'
78
+ # Unrecoverable error — requires operator intervention.
79
+ FATAL = 'FATAL'
80
+ # Operation was intentionally cancelled.
81
+ CANCELLATION = 'CANCELLATION'
82
+ # Client-side buffer overflow during disconnection.
83
+ BACKPRESSURE = 'BACKPRESSURE'
84
+ # Error in user-provided callback code.
85
+ RUNTIME = 'RUNTIME'
86
+ end
87
+
88
+ # Maps each {ErrorCode} to its {ErrorCategory} and retryable flag.
89
+ #
90
+ # Each entry is +[category, retryable]+ where +category+ is an
91
+ # {ErrorCategory} constant and +retryable+ is a Boolean.
92
+ #
93
+ # @return [Hash{String => Array(String, Boolean)}]
94
+ #
95
+ # @see ErrorCode
96
+ # @see ErrorCategory
97
+ ERROR_CLASSIFICATION = {
98
+ ErrorCode::UNAVAILABLE => [ErrorCategory::TRANSIENT, true],
99
+ ErrorCode::ABORTED => [ErrorCategory::TRANSIENT, true],
100
+ ErrorCode::CONNECTION_TIMEOUT => [ErrorCategory::TIMEOUT, true],
101
+ ErrorCode::RESOURCE_EXHAUSTED => [ErrorCategory::THROTTLING, true],
102
+ ErrorCode::AUTH_FAILED => [ErrorCategory::AUTHENTICATION, false],
103
+ ErrorCode::PERMISSION_DENIED => [ErrorCategory::AUTHORIZATION, false],
104
+ ErrorCode::VALIDATION_ERROR => [ErrorCategory::VALIDATION, false],
105
+ ErrorCode::ALREADY_EXISTS => [ErrorCategory::VALIDATION, false],
106
+ ErrorCode::OUT_OF_RANGE => [ErrorCategory::VALIDATION, false],
107
+ ErrorCode::NOT_FOUND => [ErrorCategory::NOT_FOUND, false],
108
+ ErrorCode::INTERNAL => [ErrorCategory::FATAL, false],
109
+ ErrorCode::UNKNOWN => [ErrorCategory::TRANSIENT, true],
110
+ ErrorCode::UNIMPLEMENTED => [ErrorCategory::FATAL, false],
111
+ ErrorCode::DATA_LOSS => [ErrorCategory::FATAL, false],
112
+ ErrorCode::CANCELLED => [ErrorCategory::CANCELLATION, false],
113
+ ErrorCode::BUFFER_FULL => [ErrorCategory::BACKPRESSURE, false],
114
+ ErrorCode::STREAM_BROKEN => [ErrorCategory::TRANSIENT, true],
115
+ ErrorCode::CLIENT_CLOSED => [ErrorCategory::FATAL, false],
116
+ ErrorCode::CONNECTION_NOT_READY => [ErrorCategory::TRANSIENT, true],
117
+ ErrorCode::CONFIGURATION_ERROR => [ErrorCategory::VALIDATION, false],
118
+ ErrorCode::CALLBACK_ERROR => [ErrorCategory::RUNTIME, false]
119
+ }.freeze
120
+
121
+ # Maps each {ErrorCode} to a human-readable recovery suggestion.
122
+ #
123
+ # Used by {ErrorMapper} to populate {Error#suggestion} and by
124
+ # {KubeMQ.suggestion_for} for programmatic lookup.
125
+ #
126
+ # @return [Hash{String => String}]
127
+ #
128
+ # @see Error#suggestion
129
+ ERROR_SUGGESTIONS = {
130
+ ErrorCode::UNAVAILABLE => 'Check server connectivity and firewall rules.',
131
+ ErrorCode::AUTH_FAILED => 'Verify auth token is valid and not expired.',
132
+ ErrorCode::PERMISSION_DENIED => 'Verify credentials have required permissions.',
133
+ ErrorCode::NOT_FOUND => 'Verify channel/queue exists or create it first.',
134
+ ErrorCode::VALIDATION_ERROR => 'Check request parameters.',
135
+ ErrorCode::ALREADY_EXISTS => 'Resource already exists.',
136
+ ErrorCode::CONNECTION_TIMEOUT => 'Increase timeout or check server load.',
137
+ ErrorCode::RESOURCE_EXHAUSTED => 'Reduce send rate or increase server capacity.',
138
+ ErrorCode::BUFFER_FULL => 'Wait for connection to recover or increase buffer_size.',
139
+ ErrorCode::STREAM_BROKEN => 'Subscriptions will attempt to reconnect automatically. Stream senders must be recreated.',
140
+ ErrorCode::CLIENT_CLOSED => 'The client has been closed. Create a new client instance.',
141
+ ErrorCode::INTERNAL => 'Internal server error. Check server logs.',
142
+ ErrorCode::UNKNOWN => 'An unknown error occurred. Check server logs.',
143
+ ErrorCode::CONFIGURATION_ERROR => 'Check client configuration: address, TLS settings, credentials.',
144
+ ErrorCode::CANCELLED => 'The operation was cancelled.',
145
+ ErrorCode::UNIMPLEMENTED => 'Operation not supported. Check server version.',
146
+ ErrorCode::DATA_LOSS => 'Unrecoverable data loss. Check server storage.',
147
+ ErrorCode::OUT_OF_RANGE => 'Check pagination parameters or sequence numbers.',
148
+ ErrorCode::ABORTED => 'Operation aborted due to conflict. Retry may succeed.',
149
+ ErrorCode::CONNECTION_NOT_READY => 'Wait for the client to connect or check server availability.',
150
+ ErrorCode::CALLBACK_ERROR => 'A user callback raised an exception. Fix the callback code.'
151
+ }.freeze
152
+
153
+ # Classifies an error by its {ErrorCode} into a category and retryable flag.
154
+ #
155
+ # @param error [Error] an error with a +code+ attribute
156
+ #
157
+ # @return [Array(String, Boolean)] +[category, retryable]+ from {ERROR_CLASSIFICATION};
158
+ # defaults to +[ErrorCategory::FATAL, false]+ for unknown codes
159
+ #
160
+ # @example
161
+ # category, retryable = KubeMQ.classify_error(error)
162
+ # retry if retryable
163
+ def self.classify_error(error)
164
+ return [ErrorCategory::FATAL, false] unless error.respond_to?(:code) && error.code
165
+
166
+ ERROR_CLASSIFICATION.fetch(error.code, [ErrorCategory::FATAL, false])
167
+ end
168
+
169
+ # Returns the recovery suggestion for a given error code.
170
+ #
171
+ # @param code [String] an {ErrorCode} constant value
172
+ #
173
+ # @return [String, nil] actionable suggestion, or +nil+ if the code is unknown
174
+ #
175
+ # @example
176
+ # KubeMQ.suggestion_for(KubeMQ::ErrorCode::UNAVAILABLE)
177
+ # # => "Check server connectivity and firewall rules."
178
+ def self.suggestion_for(code)
179
+ ERROR_SUGGESTIONS[code]
180
+ end
181
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grpc'
4
+
5
+ module KubeMQ
6
+ # Maps gRPC +GRPC::BadStatus+ exceptions to typed {Error} subclasses.
7
+ #
8
+ # Used internally by {Interceptors::ErrorMappingInterceptor} and subscription
9
+ # reconnect loops. Application code should rescue {Error} subclasses rather
10
+ # than raw gRPC exceptions.
11
+ #
12
+ # ## gRPC Status Code Mapping
13
+ #
14
+ # | gRPC Code | SDK Exception | Error Code | Retryable? |
15
+ # |-----------|--------------|------------|:----------:|
16
+ # | CANCELLED | {CancellationError} | CANCELLED | No |
17
+ # | UNKNOWN | {Error} | UNKNOWN | Yes |
18
+ # | INVALID_ARGUMENT | {ValidationError} | VALIDATION_ERROR | No |
19
+ # | DEADLINE_EXCEEDED | {TimeoutError} | CONNECTION_TIMEOUT | Yes |
20
+ # | NOT_FOUND | {ChannelError} | NOT_FOUND | No |
21
+ # | ALREADY_EXISTS | {ValidationError} | ALREADY_EXISTS | No |
22
+ # | PERMISSION_DENIED | {AuthenticationError} | PERMISSION_DENIED | No |
23
+ # | RESOURCE_EXHAUSTED | {MessageError} | RESOURCE_EXHAUSTED | Yes |
24
+ # | FAILED_PRECONDITION | {ValidationError} | VALIDATION_ERROR | No |
25
+ # | ABORTED | {TransactionError} | ABORTED | Yes |
26
+ # | OUT_OF_RANGE | {ValidationError} | OUT_OF_RANGE | No |
27
+ # | UNIMPLEMENTED | {Error} | UNIMPLEMENTED | No |
28
+ # | INTERNAL | {Error} | INTERNAL | No |
29
+ # | UNAVAILABLE | {ConnectionError} | UNAVAILABLE | Yes |
30
+ # | DATA_LOSS | {Error} | DATA_LOSS | No |
31
+ # | UNAUTHENTICATED | {AuthenticationError} | AUTH_FAILED | No |
32
+ #
33
+ # @see Error
34
+ # @see ErrorCode
35
+ module ErrorMapper
36
+ # Maps gRPC status codes to +[exception_class, error_code, retryable]+ tuples.
37
+ #
38
+ # @return [Hash{Integer => Array(Class, String, Boolean)}]
39
+ GRPC_MAPPING = {
40
+ GRPC::Core::StatusCodes::CANCELLED =>
41
+ [CancellationError, ErrorCode::CANCELLED, false],
42
+ GRPC::Core::StatusCodes::UNKNOWN =>
43
+ [KubeMQ::Error, ErrorCode::UNKNOWN, true],
44
+ GRPC::Core::StatusCodes::INVALID_ARGUMENT =>
45
+ [ValidationError, ErrorCode::VALIDATION_ERROR, false],
46
+ GRPC::Core::StatusCodes::DEADLINE_EXCEEDED =>
47
+ [KubeMQ::TimeoutError, ErrorCode::CONNECTION_TIMEOUT, true],
48
+ GRPC::Core::StatusCodes::NOT_FOUND =>
49
+ [ChannelError, ErrorCode::NOT_FOUND, false],
50
+ GRPC::Core::StatusCodes::ALREADY_EXISTS =>
51
+ [ValidationError, ErrorCode::ALREADY_EXISTS, false],
52
+ GRPC::Core::StatusCodes::PERMISSION_DENIED =>
53
+ [AuthenticationError, ErrorCode::PERMISSION_DENIED, false],
54
+ GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED =>
55
+ [MessageError, ErrorCode::RESOURCE_EXHAUSTED, true],
56
+ GRPC::Core::StatusCodes::FAILED_PRECONDITION =>
57
+ [ValidationError, ErrorCode::VALIDATION_ERROR, false],
58
+ GRPC::Core::StatusCodes::ABORTED =>
59
+ [TransactionError, ErrorCode::ABORTED, true],
60
+ GRPC::Core::StatusCodes::OUT_OF_RANGE =>
61
+ [ValidationError, ErrorCode::OUT_OF_RANGE, false],
62
+ GRPC::Core::StatusCodes::UNIMPLEMENTED =>
63
+ [KubeMQ::Error, ErrorCode::UNIMPLEMENTED, false],
64
+ GRPC::Core::StatusCodes::INTERNAL =>
65
+ [KubeMQ::Error, ErrorCode::INTERNAL, false],
66
+ GRPC::Core::StatusCodes::UNAVAILABLE =>
67
+ [ConnectionError, ErrorCode::UNAVAILABLE, true],
68
+ GRPC::Core::StatusCodes::DATA_LOSS =>
69
+ [KubeMQ::Error, ErrorCode::DATA_LOSS, false],
70
+ GRPC::Core::StatusCodes::UNAUTHENTICATED =>
71
+ [AuthenticationError, ErrorCode::AUTH_FAILED, false]
72
+ }.freeze
73
+
74
+ # Regex patterns for detecting credentials in error messages.
75
+ #
76
+ # @api private
77
+ CREDENTIAL_PATTERNS = [
78
+ /(?:bearer|authorization)\s*[:=]\s*\S+(?:\s+\S+)?/i,
79
+ /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/
80
+ ].freeze
81
+
82
+ module_function
83
+
84
+ # Converts a +GRPC::BadStatus+ exception to the appropriate {Error} subclass.
85
+ #
86
+ # Credential patterns (bearer tokens, JWTs) are scrubbed from error messages
87
+ # before constructing the exception. Falls back to {Error} with
88
+ # +UNKNOWN+ code for unmapped gRPC status codes.
89
+ #
90
+ # @param error [GRPC::BadStatus] the gRPC exception to map
91
+ # @param operation [String, nil] the SDK operation name for context
92
+ #
93
+ # @return [Error] a typed SDK error with code, suggestion, and cause chain
94
+ def map_grpc_error(error, operation: nil)
95
+ mapping = GRPC_MAPPING[error.code]
96
+ unless mapping
97
+ return KubeMQ::Error.new(
98
+ scrub_credentials(error.details || error.message),
99
+ code: ErrorCode::UNKNOWN,
100
+ cause: error,
101
+ operation: operation
102
+ )
103
+ end
104
+
105
+ exc_class, error_code, retryable = mapping
106
+ suggestion = KubeMQ.suggestion_for(error_code)
107
+
108
+ exc_class.new(
109
+ scrub_credentials(error.details || error.message),
110
+ code: error_code,
111
+ retryable: retryable,
112
+ cause: error,
113
+ operation: operation,
114
+ suggestion: suggestion
115
+ )
116
+ end
117
+
118
+ # Removes credential patterns from an error message string.
119
+ #
120
+ # Matches bearer/authorization headers and JWT tokens, replacing them
121
+ # with +[REDACTED]+.
122
+ #
123
+ # @param text [String, nil] the text to scrub
124
+ #
125
+ # @return [String] the scrubbed text, or an empty string if +text+ is +nil+
126
+ def scrub_credentials(text)
127
+ return '' if text.nil?
128
+
129
+ result = text.to_s
130
+ CREDENTIAL_PATTERNS.each { |pattern| result = result.gsub(pattern, '[REDACTED]') }
131
+ result
132
+ end
133
+ end
134
+ end