jevcrates.git / jev-http / src / README.md
README.mdpreviewREADME.mdsource131 lines · 6.6 KB · raw
1# Chapter 20½: inside jev-http/src — a road, a clock and a ledger
2
3Four files. [lib.rs](lib.rs) is the front door (`Jev`), and the other three
4are what it is built from: the road ([transport.rs](transport.rs)), the
5clock ([runtime.rs](runtime.rs)) and the meter ([ledger.rs](ledger.rs)).
6
7```mermaid
8flowchart TD
9  jev["lib.rs: Jev"] --> client["jev_client::Client"]
10  jev --> ledger["ledger.rs: Ledger"]
11  client --> transport["transport.rs: HttpsTransport"]
12  client --> runtime["runtime.rs: TokioRuntime"]
13  ledger --> file[("$XDG_STATE_HOME/jev/")]
14```
15
16## The files, in reading order
17
18### 1. [lib.rs](lib.rs): `Jev`, the client you keep
19
20- `KEY` is `"TYPESAFE_API_KEY"`, the variable the key arrives in.
21- `Jev::from_env(binary, model)` reads the key, opens the ledger with
22  `Ledger::open`, and builds a client for the real API. It returns `None` when
23  the key is absent or blank.
24- `Jev::new(key, binary, ledger, model, endpoint)` is the same with every
25  part given, which is how a test points it at jev-mock and a sandbox
26  ledger. It builds the rustls config: compiled-in `webpki-roots`, any PEM
27  from `Endpoint::trusting`, the RustCrypto provider, ALPN `h2`.
28- `with_guards(guards)` swaps the environment's `Guards` for others.
29- `ask(state, questions, why)` is the sequence drawn in Chapter 20: admit,
30  ask through `jev_client::Client`, then settle, or release only when
31  `ClientError::nothing_billed` says so. An out-of-credit API answer also
32  writes the ledger's out-of-credit flag. A ledger write that fails after an
33  answer arrived is logged to stderr and the answer is still returned: the
34  request was already paid for.
35- `Answered` adds to `jev-client`'s: `took` (the whole round trip), the body
36  as text, every header with credentials redacted (any name containing
37  `authorization`, `cookie`, `token`, `secret`, `-key` or `api-key` shows as
38  `«redacted»`), the request id, and a `RateLimit`.
39- `Failure` is `Throttled { why, retry_in }`, `Budget(why)` or
40  `Client(ClientError)`. The first two never touch the network.
41- `RateLimit { limit, remaining, reset_seconds }` is read from the
42  conventional `x-ratelimit-*` and `ratelimit-*` headers. The vendor
43  documents none of them on a successful response (digest §10), so it is
44  usually `None`; it exists so that the day the vendor sends one, it is
45  reported with no call site changed. `last_rate_limit()` returns the last
46  one seen, and the ledger keeps a copy in `last-rate-limit.json`.
47
48### 2. [transport.rs](transport.rs): `HttpsTransport` and `Endpoint`
49
50- `Endpoint::api()` is `jev_protocol::ENDPOINT`; `Endpoint::parse(url)`
51  accepts any `https` URL and refuses anything else; `trusting(pem)` adds a
52  CA file.
53- `HttpsTransport` keeps one HTTP/2 connection warm and reopens it when it
54  has closed, has been idle 300 s (Cloudflare closes idle connections at
55  400 s), or the client distrusts it.
56- Connecting tries each resolved address in turn, giving every address but
57  the last 2 s, so one blackholed address (a broken IPv6 route) cannot spend
58  a whole attempt. It sends an HTTP/2 PING after 20 s without a frame and
59  closes the connection if the PING goes 10 s unanswered. Flow-control
60  windows are adaptive, because hyper's fixed windows are smaller than an
61  8 MiB answer.
62- A response body over 8 MiB is `TooLarge`. A request hyper hands back
63  unsent, or one refused with `REFUSED_STREAM` or cut by a server GOAWAY, is
64  `NotSent`; any other break mid-exchange is `Interrupted`. A TLS refusal by
65  rustls, or a server that does not negotiate `h2`, is `Tls`.
66- `admit` waits on two per-process token buckets under the vendor's
67  documented ceilings, 1,200 requests a minute and 250,000 tokens a second,
68  counting the body's characters as tokens.
69
70### 3. [runtime.rs](runtime.rs): `TokioRuntime`
71
72`now` and `wall_clock` from std, `sleep` and `timeout` from tokio, and
73`jitter` from the clock's nanoseconds, which is random enough to spread
74retries and needs no dependency.
75
76### 4. [ledger.rs](ledger.rs): `Ledger`, the shared meter
77
78Its doc comment explains the design (why a file, why `flock`, which guards
79heal by themselves, why the total is re-read every time); read it first.
80Then:
81
82- `Ledger::open()` uses `$XDG_STATE_HOME/jev`, or `~/.local/state/jev`;
83  `Ledger::at(dir)` uses any directory. Either creates it and, the first time
84  ever, seeds `spend.jsonl` with one estimated opening line
85  (`OPENING_ESTIMATE_USD`, $0.1517). `create_new` makes the seed race-free.
86- Each line of `spend.jsonl` is JSON: an `Entry` (a real cost, or the
87  opening estimate) or a `Hold` (a question admitted and not yet settled),
88  told apart by the hold's `hold_id`. A line that fails to parse, such as a
89  torn write from a killed process, is skipped with a message on stderr
90  rather than failing every later read.
91- `admit(now, guards, worst_case, binary, reason)` checks the out-of-credit
92  flag, then, under one `flock`, reads every line into a `Window`, asks
93  `Window::verdict`, and appends the `Hold`. `settle(hold, entry)` books the
94  real cost; `release(hold, now, why)` books nothing but still counts the
95  question.
96- `Window` is the recent past in one pass: `lifetime_usd`, the admission
97  times of the last minute, the amounts of the last hour, and the count of
98  unsettled holds. `bucket_empty_for` and `hour_closed_for` say how long
99  until each guard would admit.
100- `Guards::from_env()` reads `JEV_MAX_QUESTIONS_PER_MINUTE` (30) and
101  `JEV_MAX_DOLLARS_PER_HOUR` (0.25).
102- `status(now, guards)` builds a `Status`, with `line()`, `stopped()` and
103  `stopped_for_good()` for display. Its hour-breaker figure is for an
104  ordinary question of a thousand input tokens.
105- `mark_out_of_credit` writes `out-of-credit.json` (the first reason is
106  kept), `credit_restored` removes it, and `out_of_credit` reads it.
107- `now()` is seconds since the epoch as an `f64`. Every guard function takes
108  `now` as an argument instead, so the tests run on any clock they like.
109
110## Try it
111
112```
113cargo test -p jev-http
114```
115
116The ledger's tests each get their own directory under the system temp dir,
117so they never touch your real ledger.
118
119## For the people who maintain it
120
121### In this folder
122
123| File | What |
124| --- | --- |
125| [lib.rs](lib.rs) | `Jev`, `Answered`, `Failure`, `RateLimit`, `KEY`; header redaction and rate-limit parsing. |
126| [transport.rs](transport.rs) | `Endpoint`, `HttpsTransport`, the courtesy buckets, connecting. |
127| [runtime.rs](runtime.rs) | `TokioRuntime`. |
128| [ledger.rs](ledger.rs) | `Ledger`, `Entry`, `Hold`, `Guards`, `Window`, `Refusal`, `Status`, `NoCredit`. |
129| [CLAUDE.md](CLAUDE.md) | Invariants for agents. |
130
131← Previous: [Chapter 20: jev-http](../) · Up: [jev-http](../) · Next: [Chapter 20¾: jev-http/tests](../tests/) →