nunki 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/docs/adr/000-template.md +16 -0
- data/docs/adr/001-explicit-network-boundary.md +23 -0
- data/docs/adr/002-mcp-transports.md +23 -0
- data/docs/adr/README.md +3 -0
- data/lib/nunki/context.rb +65 -0
- data/lib/nunki/http.rb +198 -0
- data/lib/nunki/mcp/transports.rb +215 -0
- data/lib/nunki/mcp.rb +227 -0
- data/lib/nunki/protocol.rb +80 -0
- data/lib/nunki/provider.rb +93 -0
- data/lib/nunki/providers/anthropic.rb +147 -0
- data/lib/nunki/providers/open_ai.rb +124 -0
- data/lib/nunki/sse.rb +74 -0
- data/lib/nunki/version.rb +5 -0
- data/lib/nunki.rb +81 -0
- data/sig/nunki.rbs +132 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 017ee258c9b1ee3339e0f394431890fc96069100ca3aaee503b4da00ff293ab6
|
|
4
|
+
data.tar.gz: 67fa32687f3fdb13e5c9c153ec3e6db77509aac900bfcf5b4c0ff9a7c55aa1e0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c232b4ea39676c16571b92cfbe8cc01374f4eb2d822c2d70ec44696e8c2ac9b13d8922f5bb7b8892fe1c02e3a26dde04d6e3b7d518d9b10b9d504525bb8e739d
|
|
7
|
+
data.tar.gz: 2b125428bf1c14490169d76a8e05e456e5384b82d1cadcb0b2d18f6c956a979562f8e6e08f51c4a9511326ef04d61f9e0894c07a9279c6c0323af744d0258ff7
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Nunki
|
|
2
|
+
|
|
3
|
+
Nunki is a pure Ruby, provider-neutral client for streaming LLM APIs and the
|
|
4
|
+
Model Context Protocol (MCP). It supplies protocol and transport behavior while
|
|
5
|
+
leaving prompts, UI, context selection, and edits to the host application.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle add nunki
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## LLM providers
|
|
14
|
+
|
|
15
|
+
The endpoint is always explicit. Pass the full completion/messages URL supplied
|
|
16
|
+
by your service configuration.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "nunki"
|
|
20
|
+
|
|
21
|
+
provider = Nunki::Provider.build(
|
|
22
|
+
:openai,
|
|
23
|
+
endpoint: ENV.fetch("LLM_ENDPOINT"),
|
|
24
|
+
api_key: ENV.fetch("LLM_API_KEY"),
|
|
25
|
+
model: "configured-model"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
text = Nunki::Part.new(
|
|
29
|
+
type: :text, text: "Explain this method",
|
|
30
|
+
tool_name: nil, tool_input: nil, tool_use_id: nil
|
|
31
|
+
)
|
|
32
|
+
message = Nunki::Message.new(role: :user, content: [text])
|
|
33
|
+
|
|
34
|
+
response = provider.complete([message]) do |part|
|
|
35
|
+
print part.text if part.type == :text
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Supported kinds are `:openai`, `:openai_compatible`, `:local`, and
|
|
40
|
+
`:anthropic`. The OpenAI-compatible local kind does not add an authorization
|
|
41
|
+
header unless an API key is supplied. A completion returns `Nunki::Response`;
|
|
42
|
+
tool requests appear as `:tool_use` parts containing `tool_name`, `tool_input`,
|
|
43
|
+
and `tool_use_id`. Send results back as `:tool_result` parts.
|
|
44
|
+
|
|
45
|
+
`provider.cancel` interrupts streaming or a retry wait. HTTP 429 and 5xx
|
|
46
|
+
responses are retried with exponential backoff. `dispatch:` may move blocking
|
|
47
|
+
work onto an application-owned worker:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
provider = Nunki::Provider.build(
|
|
51
|
+
:local,
|
|
52
|
+
endpoint: settings.fetch("endpoint"),
|
|
53
|
+
model: settings.fetch("model"),
|
|
54
|
+
dispatch: ->(&work) { Thread.new(&work) }
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`Nunki.estimate_tokens(value)` provides a byte-based estimate.
|
|
59
|
+
`Nunki.truncate(messages, max_tokens:)` drops the oldest messages while always
|
|
60
|
+
retaining the newest one, or raises if that message alone exceeds the budget.
|
|
61
|
+
|
|
62
|
+
## MCP
|
|
63
|
+
|
|
64
|
+
Nunki implements MCP 2025-11-25 over newline-delimited stdio and Streamable
|
|
65
|
+
HTTP. Both transports support tools, resources, prompts, pagination, request
|
|
66
|
+
timeouts, and protocol errors.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
client = Nunki::MCP::Client.stdio(command: ["my-mcp-server"])
|
|
70
|
+
# Or: Nunki::MCP::Client.http(url: settings.fetch("mcp_url"), headers: {...})
|
|
71
|
+
|
|
72
|
+
client.start
|
|
73
|
+
client.tools
|
|
74
|
+
client.call_tool("lookup", {"query" => "Ruby"})
|
|
75
|
+
client.resources
|
|
76
|
+
client.read_resource("file:///project/README.md")
|
|
77
|
+
client.prompts
|
|
78
|
+
client.get_prompt("review", {"language" => "ruby"})
|
|
79
|
+
client.close
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`close` closes a stdio server's input, waits briefly, then uses TERM and KILL if
|
|
83
|
+
needed. HTTP sessions are ended with DELETE when the server issued a session ID.
|
|
84
|
+
|
|
85
|
+
## Limits and security
|
|
86
|
+
|
|
87
|
+
Default limits are 1 MiB per HTTP request, 8 MiB per HTTP response/SSE event,
|
|
88
|
+
and 4 MiB per stdio MCP message. Provider HTTP defaults are a 10-second connect
|
|
89
|
+
timeout and 60-second read timeout; MCP requests default to 30 seconds. All are
|
|
90
|
+
configurable at construction.
|
|
91
|
+
|
|
92
|
+
Nunki deliberately accepts local and remote HTTP(S) endpoints because every
|
|
93
|
+
endpoint is configuration-derived. This means an untrusted endpoint setting is
|
|
94
|
+
an SSRF capability: hosts must validate it against their own trust policy.
|
|
95
|
+
Nunki has no default endpoints and never persists credentials; supplied keys
|
|
96
|
+
exist only in the client instance and request headers.
|
|
97
|
+
|
|
98
|
+
Nunki does not choose prompts or context, render UI, save credentials, execute
|
|
99
|
+
tool requests without a caller decision, or apply model-produced diffs.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bundle install
|
|
105
|
+
bundle exec rake test
|
|
106
|
+
bundle exec rbs -I sig validate
|
|
107
|
+
BUDGET=1 bundle exec rake bench
|
|
108
|
+
gem build --strict nunki.gemspec
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Contributing
|
|
112
|
+
|
|
113
|
+
Bug reports and pull requests are welcome at https://github.com/noxdea/nunki.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
The gem is available under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# ADR NNN: Implementation decision title
|
|
2
|
+
|
|
3
|
+
- Status: Proposed
|
|
4
|
+
- Date: YYYY-MM-DD
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Describe the concrete implementation question and its constraints.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
Describe the durable boundary or architecture choice.
|
|
13
|
+
|
|
14
|
+
## Consequences
|
|
15
|
+
|
|
16
|
+
Describe the important trade-offs and when to revisit the decision.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ADR 001: Keep network destinations explicit
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-15
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
LLM vendors and local servers expose compatible APIs at different URLs. A
|
|
9
|
+
library-selected default would couple provider protocol support to product
|
|
10
|
+
policy and could silently send data to an unintended service.
|
|
11
|
+
|
|
12
|
+
## Decision
|
|
13
|
+
|
|
14
|
+
Require a complete HTTP(S) endpoint for every provider and MCP HTTP client.
|
|
15
|
+
Adapters add protocol headers and request shapes but never select a host. API
|
|
16
|
+
keys remain in memory and are not persisted. The host application owns endpoint
|
|
17
|
+
trust and authorization policy.
|
|
18
|
+
|
|
19
|
+
## Consequences
|
|
20
|
+
|
|
21
|
+
Nunki supports hosted and local services without product-specific settings.
|
|
22
|
+
Callers must treat endpoint configuration as an SSRF-capable input and validate
|
|
23
|
+
it before constructing a client.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ADR 002: Implement the stable MCP transports directly
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-15
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Nunki needs only the MCP client lifecycle plus tools, resources, and prompts.
|
|
9
|
+
Adding a full protocol framework would expand dependencies and expose unrelated
|
|
10
|
+
host features.
|
|
11
|
+
|
|
12
|
+
## Decision
|
|
13
|
+
|
|
14
|
+
Implement MCP 2025-11-25 JSON-RPC over its stable newline-delimited stdio and
|
|
15
|
+
Streamable HTTP transports. Validate and bound every message. Serialize client
|
|
16
|
+
requests, enforce timeouts, negotiate the exact supported version, and cleanly
|
|
17
|
+
terminate stdio children and HTTP sessions.
|
|
18
|
+
|
|
19
|
+
## Consequences
|
|
20
|
+
|
|
21
|
+
The core has no runtime dependencies. Legacy HTTP+SSE, server-side features,
|
|
22
|
+
sampling, elicitation, tasks, and subscriptions remain outside 0.1.0 and can be
|
|
23
|
+
added only when a host needs them.
|
data/docs/adr/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nunki
|
|
4
|
+
module Context
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def estimate_tokens(value)
|
|
8
|
+
case value
|
|
9
|
+
when Message
|
|
10
|
+
estimate_string(value.role.to_s) + estimate_tokens(value.content)
|
|
11
|
+
when Part
|
|
12
|
+
estimate_string([value.type, value.text, value.tool_name, value.tool_input, value.tool_use_id].compact.join)
|
|
13
|
+
when Array
|
|
14
|
+
value.sum { |item| estimate_tokens(item) }
|
|
15
|
+
else
|
|
16
|
+
estimate_string(value.to_s)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def truncate(messages, max_tokens:)
|
|
21
|
+
Protocol.uint(max_tokens, "max_tokens", positive: true)
|
|
22
|
+
messages = Protocol.collection(messages, "messages")
|
|
23
|
+
validate_messages(messages)
|
|
24
|
+
|
|
25
|
+
kept = messages.dup
|
|
26
|
+
kept.shift while kept.length > 1 && estimate_tokens(kept) > max_tokens
|
|
27
|
+
raise ProtocolError, "latest message exceeds token budget" if estimate_tokens(kept) > max_tokens
|
|
28
|
+
|
|
29
|
+
kept.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate_messages(messages)
|
|
33
|
+
messages.each do |message|
|
|
34
|
+
raise ProtocolError, "message must be a Nunki::Message" unless message.is_a?(Message)
|
|
35
|
+
role = Protocol.string(message.role.to_s, "message role", empty: false, max: 32)
|
|
36
|
+
raise ProtocolError, "unsupported message role: #{role}" unless %w[system user assistant tool].include?(role)
|
|
37
|
+
Protocol.collection(message.content, "message content").each do |part|
|
|
38
|
+
raise ProtocolError, "message content must contain Nunki::Part values" unless part.is_a?(Part)
|
|
39
|
+
validate_part(part)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_part(part)
|
|
45
|
+
case part.type.to_s.to_sym
|
|
46
|
+
when :text
|
|
47
|
+
Protocol.string(part.text, "text part")
|
|
48
|
+
when :tool_use
|
|
49
|
+
Protocol.string(part.tool_name, "tool name", empty: false, max: 256)
|
|
50
|
+
Protocol.string(part.tool_use_id, "tool use id", empty: false, max: 1024)
|
|
51
|
+
Protocol.json(Protocol.object(part.tool_input, "tool input"))
|
|
52
|
+
when :tool_result
|
|
53
|
+
Protocol.string(part.tool_use_id, "tool use id", empty: false, max: 1024)
|
|
54
|
+
Protocol.string(part.text, "tool result")
|
|
55
|
+
else
|
|
56
|
+
raise ProtocolError, "unsupported part type: #{part.type}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def estimate_string(text) = [(text.bytesize + 3) / 4, 1].max
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.estimate_tokens(value) = Context.estimate_tokens(value)
|
|
64
|
+
def self.truncate(messages, max_tokens:) = Context.truncate(messages, max_tokens: max_tokens)
|
|
65
|
+
end
|
data/lib/nunki/http.rb
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Nunki
|
|
8
|
+
module HTTP
|
|
9
|
+
Result = Value.define(:status, :headers, :body, :events)
|
|
10
|
+
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_MAX_REQUEST_BYTES = 1_048_576
|
|
13
|
+
DEFAULT_MAX_RESPONSE_BYTES = 8_388_608
|
|
14
|
+
RETRY_STATUSES = [429, *(500..599)].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :endpoint
|
|
17
|
+
|
|
18
|
+
def initialize(endpoint:, headers: {}, open_timeout: 10, read_timeout: 60, retries: 2,
|
|
19
|
+
retry_base: 0.25, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
20
|
+
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
|
|
21
|
+
@endpoint = parse_endpoint(endpoint)
|
|
22
|
+
@headers = validate_headers(headers)
|
|
23
|
+
@open_timeout = positive_number(open_timeout, "open_timeout")
|
|
24
|
+
@read_timeout = positive_number(read_timeout, "read_timeout")
|
|
25
|
+
@retries = Protocol.uint(retries, "retries")
|
|
26
|
+
@retry_base = nonnegative_number(retry_base, "retry_base")
|
|
27
|
+
@max_request_bytes = Protocol.uint(max_request_bytes, "max_request_bytes", positive: true)
|
|
28
|
+
@max_response_bytes = Protocol.uint(max_response_bytes, "max_response_bytes", positive: true)
|
|
29
|
+
@state_lock = Mutex.new
|
|
30
|
+
@state_changed = ConditionVariable.new
|
|
31
|
+
@cancel_epoch = 0
|
|
32
|
+
@active = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def post_json(payload, headers: {}, &event_handler)
|
|
36
|
+
body = JSON.generate(Protocol.json(payload))
|
|
37
|
+
raise ProtocolError, "request exceeds #{@max_request_bytes} bytes" if body.bytesize > @max_request_bytes
|
|
38
|
+
|
|
39
|
+
request(Net::HTTP::Post, body, headers, &event_handler)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete(headers: {}) = request(Net::HTTP::Delete, nil, headers)
|
|
43
|
+
|
|
44
|
+
def cancel
|
|
45
|
+
connections = @state_lock.synchronize do
|
|
46
|
+
@cancel_epoch += 1
|
|
47
|
+
@state_changed.broadcast
|
|
48
|
+
@active.dup
|
|
49
|
+
end
|
|
50
|
+
connections.each do |http|
|
|
51
|
+
http.finish if http.active?
|
|
52
|
+
rescue IOError, SystemCallError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def request(request_class, body, headers)
|
|
61
|
+
epoch = @state_lock.synchronize { @cancel_epoch }
|
|
62
|
+
attempts = 0
|
|
63
|
+
loop do
|
|
64
|
+
response = perform(request_class, body, validate_headers(headers), epoch) do |event, response_headers|
|
|
65
|
+
yield event, response_headers if block_given?
|
|
66
|
+
end
|
|
67
|
+
if RETRY_STATUSES.include?(response.status) && attempts < @retries
|
|
68
|
+
attempts += 1
|
|
69
|
+
wait_retry(@retry_base * (2**(attempts - 1)), epoch)
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
raise_http_error(response) unless (200..299).cover?(response.status)
|
|
74
|
+
return response
|
|
75
|
+
end
|
|
76
|
+
rescue ::Timeout::Error => error
|
|
77
|
+
raise Cancelled, "request cancelled" if cancelled?(epoch)
|
|
78
|
+
raise Timeout, error.message
|
|
79
|
+
rescue IOError, EOFError, SocketError, SystemCallError => error
|
|
80
|
+
raise Cancelled, "request cancelled" if cancelled?(epoch)
|
|
81
|
+
raise Error, error.message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def perform(request_class, body, extra_headers, epoch)
|
|
85
|
+
raise Cancelled, "request cancelled" if cancelled?(epoch)
|
|
86
|
+
|
|
87
|
+
http = Net::HTTP.new(@endpoint.host, @endpoint.port)
|
|
88
|
+
http.use_ssl = @endpoint.scheme == "https"
|
|
89
|
+
http.open_timeout = @open_timeout
|
|
90
|
+
http.read_timeout = @read_timeout
|
|
91
|
+
@state_lock.synchronize do
|
|
92
|
+
raise Cancelled, "request cancelled" unless @cancel_epoch == epoch
|
|
93
|
+
@active << http
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
request = request_class.new(request_target)
|
|
97
|
+
@headers.merge(extra_headers).each { |key, value| request[key] = value }
|
|
98
|
+
if body
|
|
99
|
+
request["Content-Type"] ||= "application/json"
|
|
100
|
+
request.body = body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result = nil
|
|
104
|
+
http.start do
|
|
105
|
+
raise Cancelled, "request cancelled" if cancelled?(epoch)
|
|
106
|
+
http.request(request) do |response|
|
|
107
|
+
result = read_response(response, epoch) do |event, response_headers|
|
|
108
|
+
yield event, response_headers if block_given?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
result
|
|
113
|
+
ensure
|
|
114
|
+
@state_lock.synchronize { @active.delete(http) } if http
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_response(response, epoch)
|
|
118
|
+
headers = response.each_header.to_h.freeze
|
|
119
|
+
parser = SSE::Parser.new(max_event_bytes: @max_response_bytes) if sse?(headers)
|
|
120
|
+
bytes = 0
|
|
121
|
+
body = +""
|
|
122
|
+
events = []
|
|
123
|
+
|
|
124
|
+
response.read_body do |chunk|
|
|
125
|
+
raise Cancelled, "request cancelled" if cancelled?(epoch)
|
|
126
|
+
bytes += chunk.bytesize
|
|
127
|
+
raise ProtocolError, "response exceeds #{@max_response_bytes} bytes" if bytes > @max_response_bytes
|
|
128
|
+
if parser
|
|
129
|
+
parser.feed(chunk).each do |event|
|
|
130
|
+
events << event
|
|
131
|
+
yield event, headers if block_given? && response.is_a?(Net::HTTPSuccess)
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
body << chunk
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
parser&.finish&.each do |event|
|
|
138
|
+
events << event
|
|
139
|
+
yield event, headers if block_given? && response.is_a?(Net::HTTPSuccess)
|
|
140
|
+
end
|
|
141
|
+
Result.new(status: response.code.to_i, headers: headers, body: body.freeze, events: events.freeze)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def raise_http_error(result)
|
|
145
|
+
retry_after = Float(result.headers["retry-after"], exception: false)
|
|
146
|
+
raise RateLimited.new(result.body, retry_after: retry_after) if result.status == 429
|
|
147
|
+
raise HTTPError.new(result.status, result.body)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def wait_retry(seconds, epoch)
|
|
151
|
+
@state_lock.synchronize do
|
|
152
|
+
@state_changed.wait(@state_lock, seconds) if @cancel_epoch == epoch && !seconds.zero?
|
|
153
|
+
raise Cancelled, "request cancelled" unless @cancel_epoch == epoch
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def cancelled?(epoch) = @state_lock.synchronize { @cancel_epoch != epoch }
|
|
158
|
+
|
|
159
|
+
def sse?(headers)
|
|
160
|
+
headers.fetch("content-type", "").downcase.start_with?("text/event-stream")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def request_target
|
|
164
|
+
path = @endpoint.path.empty? ? "/" : @endpoint.path
|
|
165
|
+
@endpoint.query ? "#{path}?#{@endpoint.query}" : path
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_endpoint(endpoint)
|
|
169
|
+
uri = URI.parse(Protocol.string(endpoint, "endpoint", empty: false, max: 4096))
|
|
170
|
+
valid = %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.fragment
|
|
171
|
+
raise ProtocolError, "endpoint must be an HTTP(S) URL without credentials or fragment" unless valid
|
|
172
|
+
uri
|
|
173
|
+
rescue URI::InvalidURIError => error
|
|
174
|
+
raise ProtocolError, "invalid endpoint: #{error.message}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_headers(headers)
|
|
178
|
+
raise ProtocolError, "headers must be an object" unless headers.is_a?(Hash)
|
|
179
|
+
headers.to_h do |key, value|
|
|
180
|
+
key = Protocol.string(key.to_s, "header name", empty: false, max: 256)
|
|
181
|
+
value = Protocol.string(value, "header value", max: 8192)
|
|
182
|
+
raise ProtocolError, "headers must not contain newlines" if key.match?(/[\r\n]/) || value.match?(/[\r\n]/)
|
|
183
|
+
[key, value]
|
|
184
|
+
end.freeze
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def positive_number(value, name)
|
|
188
|
+
raise ProtocolError, "#{name} must be positive" unless value.is_a?(Numeric) && value.positive?
|
|
189
|
+
value
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def nonnegative_number(value, name)
|
|
193
|
+
raise ProtocolError, "#{name} must not be negative" unless value.is_a?(Numeric) && value >= 0
|
|
194
|
+
value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|