|
| 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