Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

didww/jrpc

Repository files navigation

JRPC

Gem Version CI

A JSON-RPC v2 client for Ruby, over TCP, with netstring framing.

JRPC ships two clients with sharp, separate responsibilities:

JRPC::SimpleClient JRPC::SharedClient
Concurrency single thread/fiber only shared across many threads and/or fibers
Connection one socket, lazy connect one shared socket, dedicated transport thread
Multiplexing one in-flight call at a time many in-flight calls, id-demuxed
Timeouts per-call read_timeout/write_timeout per-message ttl
Use it for CLI tools, scripts, one-shot calls, per-thread pools Rails+Puma, rage-rb, Falcon, any long-lived shared client

Pick SimpleClient unless you need one client instance to serve concurrent callers. It is not thread-safe or fiber-safe; use one instance per thread/fiber (or a pool). Pick SharedClient when a single process-wide instance must serve many caller threads or fibers over a single connection.

Installation

gem 'jrpc'
$ bundle install

Requires Ruby >= 3.3. Fiber callers additionally require a spec-compliant Fiber.scheduler (e.g. async) on their thread — see Fiber callers.

SimpleClient

client = JRPC::SimpleClient.new(
 "127.0.0.1:1234",
 connect_timeout: 60, # total wall-clock budget for connect, across retries (seconds)
 read_timeout: 60,
 write_timeout: 60,
 connect_retry_count: 0, # retries after the first failed connect
 autoclose: false, # close the socket after every call
 id_prefix: nil, # random per instance if nil
 tcp_md5_pass: nil, # RFC2385 TCP MD5 Signature key (Linux-only); nil disables
 logger: nil # when set, logs every wire payload at DEBUG; nil disables
)
result = client.request(:sum, [1, 2])
result = client.request(:sum, [1, 2], read_timeout: 10, write_timeout: 10)
client.notification(:log, { msg: "hi" })
client.notification(:log, { msg: "hi" }, write_timeout: 10)
client.close # terminal; the instance cannot be reused
client.closed? # => true
client.server # => "127.0.0.1:1234"

Behavior:

  • The constructor does not open the connection. The first request/notification connects.
  • autoclose: true closes the socket in an ensure after each call. The client is still reusable — the next call reconnects. autoclose controls the socket, not the client.
  • #close is terminal. After it, #closed? is true and every call raises ClientError("client closed"). There is no reopen — make a new client.
  • Not thread-safe, not fiber-safe.

SharedClient

One instance, one connection, many concurrent callers. A dedicated transport thread owns the socket and demultiplexes responses by id.

client = JRPC::SharedClient.new(
 "127.0.0.1:1234",
 connect_timeout: 60,
 connect_retry_count: 0,
 connect_retry_interval: 0.5,
 write_timeout: 5, # MUST be < default_ttl (see below)
 reap_timeout: nil, # nil = never close an idle connection
 default_ttl: 30, # per-message lifetime, seconds
 max_queue_size: 10_000, # bounded; pass nil for unbounded (opt-in OOM risk)
 id_prefix: nil,
 tcp_md5_pass: nil, # RFC2385 TCP MD5 Signature key (Linux-only); nil disables
 logger: nil # when set, logs every wire payload at DEBUG; nil disables
)
result = client.request(:sum, [1, 2])
result = client.request(:sum, [1, 2], ttl: 10)
client.notification(:log, { msg: "hi" })
client.notification(:log, { msg: "hi" }, ttl: 5)
client.notification(:metric, [1], fire_and_forget: true) # send errors/TTL expiry are logged, not raised
client.close # graceful shutdown, default timeout: 5 seconds
client.close(timeout: 10)
client.closed?
client.server

Behavior:

  • TTL, not per-call timeout. Each message carries expires_at = now + ttl. The transport thread is the timer authority; the caller blocks until the message resolves, fails, or its TTL elapses. ttl: nil blocks forever (opt-in).
  • write_timeout < default_ttl is enforced. While the transport thread is parked in a single write_frame, it cannot fire TTL deadlines for other messages, so write_timeout is the maximum TTL-firing lag. The constructor raises ArgumentError if write_timeout >= default_ttl.
  • notification blocks until sent by default (send errors propagate). Pass fire_and_forget: true to return immediately; then send errors and TTL expiry are logged, not raised. request never accepts fire_and_forget.
  • Bounded queue. When the outbound queue is at max_queue_size, enqueue raises ClientError("queue full").
  • Connection drops resolve every in-flight request with ConnectionError; the transport thread keeps running and reconnects on the next message.
  • Reaping. With reap_timeout set, the connection closes after that many idle seconds (no in-flight messages, empty queue, no bytes received) and reopens on the next message.
  • #close is graceful: it lets in-flight work drain up to timeout, then force-closes. It returns true on a clean join, false on a forced close. Idempotent.
  • A crash in the transport thread is surfaced, not hidden: in-flight requests fail with ConnectionError, the client transitions to an unusable state, and every subsequent call raises ClientError("client unusable: transport thread exited"). The client does not auto-restart — instantiate a new one.

Fiber callers

SharedClient is shareable across the full Ruby concurrency matrix:

Deployment Caller is a...
Rails + Puma Thread
rage-rb Fiber under a single-thread Async reactor
Rails + Falcon Fiber under a multi-thread Async reactor
Mixed Some threads, some fibers, one client instance

A caller blocks in a scheduler-aware wait, so a fiber under Async/Falcon/rage-rb yields to the reactor instead of stalling its OS thread; other fibers keep running and the response is routed back to the right fiber. This requires:

  • Ruby >= 3.3 (where the ConditionVariableFiber.scheduler cooperation is verified), and
  • a spec-compliant Fiber.scheduler active on the caller's thread, with correct cross-thread unblock (Async and Polyphony qualify).

No scheduler library is a runtime dependency — callers bring their own. Plain (non-scheduler) fibers are unsupported: they would block the OS thread on every wait. Use SimpleClient for non-scheduler code.

Errors

All errors live under JRPC::Errors::* and descend from JRPC::Errors::Error. The four public-facing classes are siblings (no inheritance between them), so rescue each by name or rescue Errors::Error to catch all:

Errors::Error (RuntimeError)
├── Errors::ClientError # caller-side: bad args, bad URI, client closed, queue full
├── Errors::ConnectionError # cannot connect, or connection died (see Exception#cause)
├── Errors::Timeout # message TTL elapsed, or SimpleClient read/write/connect timeout
└── Errors::ServerError # peer returned an error, or the response was unusable
 attr_reader :code # nil for malformed responses
 ├── Errors::ParseError # -32700
 ├── Errors::InvalidRequest # -32600
 ├── Errors::MethodNotFound # -32601
 ├── Errors::InvalidParams # -32602
 ├── Errors::InternalError # -32603
 ├── Errors::InternalServerError # -32099..-32000
 ├── Errors::UnknownError # any other code
 └── Errors::MalformedResponseError # bad framing/JSON, id mismatch, wrong jsonrpc version

MalformedResponseError is a ServerError, not a ClientError: a malformed response is the peer's fault.

begin
 client.request(:do_thing, [1, 2])
rescue JRPC::Errors::ServerError => e
 warn "rpc error #{e.code}: #{e.message}"
rescue JRPC::Errors::Timeout
 warn "timed out"
rescue JRPC::Errors::ConnectionError => e
 warn "connection: #{e.message} (cause: #{e.cause})"
end

JSON serialization

JRPC uses the stdlib json. To swap in oj for speed, monkey-patch it yourself before use:

require 'oj'
Oj.mimic_JSON

TCP MD5 Signature (RFC2385)

Both clients accept tcp_md5_pass: to enable per-connection authentication via the TCP MD5 Signature option. The kernel signs and verifies every TCP segment with MD5(key + segment + addresses/ports); a peer with a mismatched or absent key has its segments silently dropped, so the handshake never completes.

client = JRPC::SimpleClient.new("10.0.0.2:1234", tcp_md5_pass: "shared-secret")
  • Linux-only. It relies on the TCP_MD5SIG socket option (and a kernel built with CONFIG_TCP_MD5SIG). When tcp_md5_pass is set on a platform/kernel without it, the first connect raises ConnectionError — the option never silently no-ops.
  • The server must be configured with the same key for this client's address. JRPC only sets the client side; the peer (e.g. a router/BGP-style endpoint, or another socket with a matching TCP_MD5SIG) must agree on the key.
  • Key length is capped at 80 bytes (TCP_MD5SIG_MAXKEYLEN); a longer key raises ConnectionError.
  • The key is installed on the socket before connect, so it also protects the handshake itself. It survives reconnects (reaping, connection drops) transparently.

Testing

JRPC::Transport::Test is an in-process transport double for testing code that talks to a JSON-RPC server, without standing up a real one. It is not loaded by default — require it explicitly from your test setup:

require 'jrpc/transport/test'
transport = JRPC::Transport::Test.new
transport.on('sum') { |params| params['a'] + params['b'] }
client = JRPC::SimpleClient.new('test', transport: transport)
client.request('sum', { 'a' => 1, 'b' => 2 }) # => 3
transport.last_request # => { "jsonrpc" => "2.0", "method" => "sum", "params" => {...}, "id" => "..." }

Inject it through the transport: option of either SimpleClient or SharedClient.

Handlers are the high-level API. A handler's return value is encoded as a result response echoing the request id. Raise to produce other outcomes:

# JSON-RPC error response (mapped back to the matching JRPC::Errors class on the caller):
transport.on('lookup') { raise JRPC::Errors::MethodNotFound, 'no such method' }
# Simulated socket-level failure, raised when the client reads the response:
transport.on('flaky') { raise JRPC::Transport::Base::ConnectionError, 'peer reset' }

In strict mode (the default) a request for a method with no handler raises JRPC::Transport::Test::UnexpectedRequest at write time, so a missing stub fails loudly instead of hanging. Pass strict: false to drive reads entirely with the raw escape hatch:

transport = JRPC::Transport::Test.new(strict: false)
# Feed literal response frames — for malformed responses, id mismatches, orphans:
transport.push_response({ 'jsonrpc' => '2.0', 'id' => 'abc', 'result' => 42 })
transport.push_raise(JRPC::Transport::Base::MalformedFrame.new('garbage'))

Other helpers: fail_connect(error) arms connect to raise; requests, notifications, and sent expose recordings for assertions; reset clears recordings and queued frames (keeping handlers). The transport opens a Unix socketpair so SharedClient's IO.select loop works — call shutdown (e.g. in an after hook) for deterministic FD cleanup, or let the GC finalizer reclaim it.

CLI tools

Two executables ship with the gem:

  • jrpc — one-shot request/notification from the shell (jrpc --help).
  • jrpc-shell — an interactive REPL (connect, request, notification, disconnect).

Both use SimpleClient.

Upgrading from 1.x

2.0 is a full rewrite with many breaking changes (JRPC::TcpClient/BaseClient removed, error constants moved under JRPC::Errors::*, method_missing/namespace: dropped, no eager connect, and more). See the CHANGELOG for the complete list.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/didww/jrpc.

License

The gem is available as open source under the terms of the MIT License.

About

JSON RPC TCP Client

Resources

License

Stars

Watchers

Forks

Packages

Contributors

AltStyle によって変換されたページ (->オリジナル) /