Skip to content

Commit 3c1ef9f

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat: add QUIC/HTTP3 transport with automatic protocol negotiation
Implements a QUIC/HTTP3 client via Rust NIF (quinn + h3) with graceful fallback to HTTP/2 → HTTP/1.1 when the NIF is not compiled. Features include 0-RTT connection resumption, per-host protocol caching via ETS, latency tracking for adaptive selection, and full SSRF protection via the existing Verified.Url layer. New modules: - Opsm.Transport.Protocol — protocol negotiation with ETS cache - Opsm.Transport.Quic — high-level QUIC/HTTP3 API with fallback - Opsm.Transport.QuicNif — NIF stubs for QUIC operations - native/quic_transport — Rust NIF using quinn, h3, rustler - VerifiedHttp.get_quic/get_json_quic — transport-aware variants Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 410a84b commit 3c1ef9f

10 files changed

Lines changed: 1414 additions & 2 deletions

File tree

opsm_ex/lib/opsm/application.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ defmodule Opsm.Application do
1010

1111
@impl true
1212
def start(_type, _args) do
13+
# Initialize transport protocol cache (ETS tables for QUIC/HTTP3 negotiation)
14+
Opsm.Transport.Quic.init()
15+
1316
children = [
1417
RegistryGateway.Store,
1518
{Bandit, plug: RegistryGateway.Router, scheme: :http, port: registry_port(), ip: {127, 0, 0, 1}},
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule Opsm.Transport.Protocol do
3+
@moduledoc """
4+
Transport protocol negotiation and abstraction layer.
5+
6+
Provides automatic protocol selection with graceful degradation:
7+
QUIC/HTTP3 → HTTP/2 → HTTP/1.1
8+
9+
The transport layer sits between VerifiedHttp and the actual network I/O,
10+
allowing OPSM to leverage QUIC's advantages (0-RTT, multiplexing, connection
11+
migration) when available while falling back to HTTP/2 or HTTP/1.1.
12+
13+
Protocol selection is per-host and cached in an ETS table for the session
14+
lifetime, avoiding repeated negotiation overhead.
15+
"""
16+
17+
require Logger
18+
19+
@type protocol :: :quic | :http2 | :http1
20+
@type transport_result :: {:ok, protocol(), map()} | {:error, term()}
21+
22+
# ETS table for caching protocol support per host
23+
@cache_table :opsm_protocol_cache
24+
# Cache entry TTL: 5 minutes
25+
@cache_ttl_ms 300_000
26+
27+
@doc """
28+
Initialize the protocol cache. Called at application startup.
29+
"""
30+
@spec init() :: :ok
31+
def init do
32+
if :ets.whereis(@cache_table) == :undefined do
33+
:ets.new(@cache_table, [:named_table, :public, :set, read_concurrency: true])
34+
end
35+
36+
:ok
37+
end
38+
39+
@doc """
40+
Determine the best available protocol for a given host.
41+
42+
Checks (in order):
43+
1. ETS cache for a recent probe result
44+
2. QUIC availability (NIF loaded + host supports Alt-Svc h3)
45+
3. Falls back to HTTP/2 or HTTP/1.1
46+
47+
Returns the recommended protocol atom.
48+
"""
49+
@spec negotiate(String.t()) :: protocol()
50+
def negotiate(host) do
51+
case cached_protocol(host) do
52+
{:ok, proto} ->
53+
proto
54+
55+
:miss ->
56+
proto = probe_host(host)
57+
cache_protocol(host, proto)
58+
proto
59+
end
60+
end
61+
62+
@doc """
63+
Returns whether QUIC/HTTP3 transport is available on this system.
64+
65+
Requires the Rust NIF to be compiled and loaded.
66+
"""
67+
@spec quic_available?() :: boolean()
68+
def quic_available? do
69+
Opsm.Transport.Quic.available?()
70+
end
71+
72+
@doc """
73+
Returns the list of supported protocols in preference order.
74+
"""
75+
@spec supported_protocols() :: [protocol()]
76+
def supported_protocols do
77+
if quic_available?() do
78+
[:quic, :http2, :http1]
79+
else
80+
[:http2, :http1]
81+
end
82+
end
83+
84+
@doc """
85+
Get transport statistics for a host.
86+
"""
87+
@spec host_stats(String.t()) :: map()
88+
def host_stats(host) do
89+
case :ets.lookup(@cache_table, {:stats, host}) do
90+
[{_, stats}] -> stats
91+
[] -> %{requests: 0, avg_latency_ms: 0, protocol: :unknown}
92+
end
93+
rescue
94+
ArgumentError -> %{requests: 0, avg_latency_ms: 0, protocol: :unknown}
95+
end
96+
97+
@doc """
98+
Record a request's latency for adaptive protocol selection.
99+
"""
100+
@spec record_latency(String.t(), protocol(), non_neg_integer()) :: :ok
101+
def record_latency(host, protocol, latency_ms) do
102+
key = {:stats, host}
103+
104+
stats =
105+
case :ets.lookup(@cache_table, key) do
106+
[{_, existing}] -> existing
107+
[] -> %{requests: 0, total_latency_ms: 0, protocol: protocol}
108+
end
109+
110+
updated = %{
111+
stats
112+
| requests: stats.requests + 1,
113+
total_latency_ms: Map.get(stats, :total_latency_ms, 0) + latency_ms,
114+
protocol: protocol
115+
}
116+
117+
avg = if updated.requests > 0, do: div(updated.total_latency_ms, updated.requests), else: 0
118+
:ets.insert(@cache_table, {key, Map.put(updated, :avg_latency_ms, avg)})
119+
:ok
120+
rescue
121+
ArgumentError -> :ok
122+
end
123+
124+
@doc """
125+
Clear the protocol cache (useful for testing or after network changes).
126+
"""
127+
@spec clear_cache() :: :ok
128+
def clear_cache do
129+
if :ets.whereis(@cache_table) != :undefined do
130+
:ets.delete_all_objects(@cache_table)
131+
end
132+
133+
:ok
134+
end
135+
136+
@doc """
137+
Force a specific protocol for a host (for testing or user overrides).
138+
"""
139+
@spec force_protocol(String.t(), protocol()) :: :ok
140+
def force_protocol(host, protocol) when protocol in [:quic, :http2, :http1] do
141+
cache_protocol(host, protocol)
142+
:ok
143+
end
144+
145+
# =============================================================================
146+
# Internal: Cache Operations
147+
# =============================================================================
148+
149+
defp cached_protocol(host) do
150+
case :ets.lookup(@cache_table, {:proto, host}) do
151+
[{_, {proto, timestamp}}] ->
152+
if System.monotonic_time(:millisecond) - timestamp < @cache_ttl_ms do
153+
{:ok, proto}
154+
else
155+
:miss
156+
end
157+
158+
[] ->
159+
:miss
160+
end
161+
rescue
162+
ArgumentError -> :miss
163+
end
164+
165+
defp cache_protocol(host, proto) do
166+
timestamp = System.monotonic_time(:millisecond)
167+
:ets.insert(@cache_table, {{:proto, host}, {proto, timestamp}})
168+
rescue
169+
ArgumentError -> :ok
170+
end
171+
172+
# =============================================================================
173+
# Internal: Host Probing
174+
# =============================================================================
175+
176+
defp probe_host(host) do
177+
if quic_available?() && quic_probe(host) do
178+
Logger.debug("QUIC/HTTP3 available for #{host}")
179+
:quic
180+
else
181+
# HTTP/2 is widely supported; Req/Finch handles ALPN negotiation.
182+
# We optimistically assume HTTP/2 is available for HTTPS hosts.
183+
Logger.debug("HTTP/2 available for #{host}")
184+
:http2
185+
end
186+
end
187+
188+
defp quic_probe(host) do
189+
case Opsm.Transport.Quic.probe(host, 443) do
190+
{:ok, true} -> true
191+
_ -> false
192+
end
193+
end
194+
end

0 commit comments

Comments
 (0)