Fetch Flow
Single URL Fetch
Section titled “Single URL Fetch”Step-by-step what happens inside client.Fetch(ctx, url):
client.Fetch(ctx, "https://techcrunch.com/premium/article.html") | | 1. Parse domain from URL | domain = "techcrunch.com" | | 2. Resolve Exchanges | ExchangeRegistry.Resolve(domain) | - Check in-memory cache for domain -> Exchange mappings | - Cache hit: return known Exchange endpoints | - Cache miss: fetch https://techcrunch.com/.well-known/fora.json | - Parse WellKnownManifest, extract authorized Exchanges | - Cache result (TTL: 1 hour, configurable) | - If fora.json fetch fails: attempt direct GET to URL | - If 403: read the discovery headers (ParseDiscoveryHint) | - X-FORA-Exchange: an Exchange domain; resolve its endpoint | from that Exchange's own fora.json, never from the header | - X-Content-Rules: the publisher's fora.json URL; read it and | confirm the hinted Exchange is listed (ReconcileDiscoveryHint) | - If 403 without header or non-403: return NoExchangeError | | 3. Pre-flight budget check | BudgetTracker.CanSpend(MaxPerRequest) | - If session budget exhausted: return BudgetExceededError | - If period budget exhausted: return BudgetExceededError | | 4. Discover supply | SupplyDiscoverer.Discover(ctx, exchanges, url) | - Build ResourceQuery with AISystem | - Authenticate via RFC 9421 HTTP Message Signature in the HTTP headers | - Fan out DiscoverResources RPC to all resolved Exchanges (parallel) | - Collect ResourceResponses (respect deadline, drop slow responders) | - Flatten all Offers into candidate list | | 5. Select best offer | SelectionEngine.Select(candidates, constraints) | - Choose the Offer whose term's restrictions | (function, geo, user-type) the agent can honour | - Group by ResourceIdentity (deduplication) | - Rank: subscription offers first (rate=0) | -> preferred Exchange offers second | -> lowest unit_cost third | - Return winning Offer + its Exchange | | 6. Budget check (exact amount) | BudgetTracker.Check(offer.Pricing.Rate) | - If offer.rate > MaxPerRequest: return BudgetExceededError | - If session cumulative + rate > MaxPerSession: return BudgetExceededError | - If period cumulative + rate > MaxPerPeriod: return BudgetExceededError | | 7. Execute transaction | TransactionExecutor.Execute(ctx, exchange, offer) | - Build TransactionRequest embedding the full signed offer (reflected back) | - Authenticate via RFC 9421 HTTP Message Signature in the HTTP headers | - Send ExecuteTransaction RPC to winning Exchange | - On DenialReason: return typed error (see Error Handling below) | - On success: extract signed URL from retrieval_endpoint, as issued. | The SDK does not verify it; the delivery edge does, at step 9 | | 8. Record spend | BudgetTracker.Record(txn.Cost) | - Add to session cumulative | - Add to period cumulative (persisted) | | 9. Fetch content | ContentFetcher.Fetch(ctx, signedURL) | - GET signed URL: present agent public key + RFC 9421 signature | - The edge verifies the URL signature and, where it can, the agent | binding against that proof | - Read response body | - On HTTP error: return ContentFetchError | | 9b. Verify attestation (v1.0) | AttestationVerifier.Verify(offer.Attestations, contentBytes) | - Level 1 (self-attested): compute SHA-256 of received bytes, | compare to attested content_hash. Mismatch = integrity violation. | - Level 2 (third-party): trust attestation, check attested_at | freshness against agent policy threshold. | - Level 0 (no attestation): skip verification. | - On mismatch and AutoDispute enabled: | file UsageReport -> get report_id -> file DisputeRequest | with reason CONTENT_MISMATCH | | 10. Auto-report usage (background, non-blocking) | UsageReporter.Enqueue(UsageReport{ | TransactionID, BillingID, Function, TokenCount, | Attribution, ... | }) | - Queued in memory, submitted by background goroutine | - Deadline tracked per ReportingObligation.window | - UsageReportResponse returns report_id (stored on FetchResult) | - report_id is required for any subsequent DisputeRequest | | 11. Return FetchResult | FetchResult{Content, Cost, BillingID, TransactionID, ReportID, | Exchange, Attestations, AttestationResult, ...}Latency Budget (Single URL)
Section titled “Latency Budget (Single URL)”| Step | Target (p50) | Target (p99) | Notes |
|---|---|---|---|
| Parse domain | <1ms | <1ms | String operation |
| Resolve Exchanges (cache hit) | <1ms | <1ms | In-memory lookup |
| Resolve Exchanges (cache miss) | 50ms | 200ms | HTTP fetch of fora.json |
| Pre-flight budget check | <1ms | <1ms | In-memory arithmetic |
| DiscoverResources RPC | 10ms | 50ms | Per NFR targets |
| Select best offer | <1ms | <1ms | In-memory sort |
| Budget check (exact) | <1ms | <1ms | In-memory arithmetic |
| ExecuteTransaction RPC | 20ms | 100ms | Per NFR targets |
| Record spend | <1ms | <1ms | In-memory write |
| Fetch content | 20ms | 200ms | CDN-dependent |
| Enqueue report | <1ms | <1ms | Channel write |
| Total (cache hit) | ~55ms | ~355ms | Competitive with normal web request |
Batch Fetch
Section titled “Batch Fetch”Step-by-step what happens inside client.FetchBatch(ctx, urls):
client.FetchBatch(ctx, [url1, url2, url3]) | | 1. Group URLs by domain | {"techcrunch.com": [url1, url2], "arstechnica.com": [url3]} | | 2. Resolve Exchanges per domain (parallel) | ExchangeRegistry.Resolve(domain) for each unique domain | | 3. Pre-flight budget check | BudgetTracker.CanSpend(MaxPerRequest * len(urls)) | - Rough check: enough headroom for worst case? | | 4. Discover supply (parallel, multi-URI per Exchange) | For each Exchange: send ONE ResourceQuery with ALL URIs it covers | - Exchange returns OfferGroups (one per URI) | - If an Exchange covers multiple domains, one query per domain | | 5. Select best offer per URI | For each URI: merge offers from all Exchanges, select best | - Same ranking: subscription > preferred > lowest unit_cost | - Deduplicate by ResourceIdentity across Exchanges | | 6. Budget check (total) | Sum selected offer costs across all URIs | BudgetTracker.Check(totalCost) | - If total > remaining session budget: return BudgetExceededError | - If total > remaining period budget: return BudgetExceededError | - Individual MaxPerRequest checked per URI | | 7. Execute transactions (batch, grouped by Exchange) | Group selected offers by winning Exchange | For each Exchange: send ONE batch TransactionRequest with items[] | - Each item: the full signed offer (reflected back) | - Exchange returns TransactionResultItem per offer | - Individual items can fail (non-atomic batch) | | 8. Record spend (per item) | BudgetTracker.Record(item.Cost) for each successful item | | 9. Fetch content (parallel) | ContentFetcher.FetchAll(ctx, signedURLs) | - Concurrent GET requests: present agent public key + RFC 9421 signature | - Individual URLs can fail independently | | 10. Auto-report usage (per item, background) | UsageReporter.Enqueue(report) for each successfully fetched item | - One UsageReport per resource (not per batch) | | 11. Return []FetchResult | One FetchResult per input URL (Err set for failures)Batch Optimizations
Section titled “Batch Optimizations”- One ResourceQuery per Exchange: N URLs in one request instead of N separate requests. Reduces network round trips from N*M to M (where M = number of Exchanges).
- One TransactionRequest per winning Exchange: batch items[] instead of individual transactions.
- Parallel content fetch: all signed URLs fetched concurrently after all transactions complete.
- Non-atomic: individual URLs can fail while others succeed. Each FetchResult carries its own Err.
Code Example: Single URL
Section titled “Code Example: Single URL”Everything above this section describes the planned one-call layer. This section and the next show the shipped client, which does the same work in four verbs. Both run in Go, TypeScript and Python; the Go form is below, and the Python README carries the same flow with its example executed by the package’s own test suite.
forav1 "github.com/FORA-Protocol/protocol/gen/go/fora/v1""github.com/FORA-Protocol/protocol/gen/go/vocab/functiontokens""github.com/FORA-Protocol/protocol/sdk/go/connect""github.com/FORA-Protocol/protocol/sdk/go/helpers""github.com/FORA-Protocol/protocol/sdk/go/resolvers"
// The endpoint an Exchange serves comes from its own /.well-known/fora.json,// never from configuration; the same resolver routes usage reports back to// whichever Exchange issued the offer.endpoints := resolvers.NewWellKnownEndpointResolver(resolvers.WellKnownOptions{})
client := connect.NewClient(baseURL, connect.WithSigner(signer), // RFC 9421 request signing; custody stays yours connect.WithAgentKey(agentPublic), // the public half a bound delivery fetch presents connect.WithRequester(requester), // who this agent says it is connect.WithOfferKey(exchangePublic), // the key this Exchange signs offers with connect.WithEndpointResolver(endpoints), // The WBA directory where THIS agent publishes its own signing key, as a JWK // Set at {origin}/.well-known/http-message-signatures-directory. The Exchange // reads it off the covered Signature-Agent header and looks there for the key // whose RFC 7638 thumbprint equals the keyid. Signature-Agent is covered // whether or not it is set, so leaving this out signs an EMPTY value that no // Exchange can resolve a key from, and the call is refused with a 401 after it // was routed, signed and sent. Publish the directory before you call. connect.WithSignatureAgent("https://agent.example"),)
// 1. Discover. Offers arrive already sorted into verified and rejected, and a// rejected one keeps the reason it was refused.found, err := client.Discover(ctx, &forav1.ResourceQuery{ Exchange: "exchange.example", Uris: []string{"https://publisher.example/article"}, // Which domains you work in is a property of the query rather than of the // client, so it goes here. SupportedProfiles: []string{ "fora-news-v1", // articles, podcasts, broadcasting "fora-academic-v1", // journal papers, preprints, datasets "fora-legal-v1", // legislation, case law, patents },})if err != nil { return err}offers := found.Verified()if len(offers) == 0 { return fmt.Errorf("no verifiable offer: %v", found.Rejected())}
// 2. Buy. Execute accepts only a verified offer, so an unverified one cannot be// paid for by mistake.tx, err := client.Execute(ctx, offers[0])if err != nil { return err}item := tx.GetItems()[0]
// 3. Fetch. The delivery URL is bound to the agent's key and the client presents// the matching proof of possession, so a copied link fetches nothing.content, err := client.Fetch(ctx, item.GetRetrievalEndpoint())if err != nil { return err}
// 4. Report what was used. ConsumedQuantity is the billed quantity, so a report// without it bills nothing. Content.Body holds the fetched bytes, and function// tokens come from the generated vocabulary rather than a string literal.resp, err := client.ReportUsage(ctx, &forav1.UsageReport{ Exchange: "exchange.example", // the Exchange that issued the offer TransactionId: item.GetTransactionId(), BillingId: item.GetBillingId(), Usage: &forav1.Usage{ ConsumedQuantity: int32(len(content.Body)), Function: []string{functiontokens.AiInput}, },})if err != nil { return err}// resp.GetReportId() is what a later dispute references.Code Example: Several URLs
Section titled “Code Example: Several URLs”The single-call FetchBatch above is part of the deferred layer. What ships is the
part that matters most for round trips: ResourceQuery.uris is a repeated field, so
one Discover call covers every URI you want from one Exchange. Purchase can be per
offer, as below, or batched: Client.ExecuteBatch buys several offers from one Exchange
in one request, and BrokerClient.Execute buys offers from several Exchanges in one call
to a Broker (shown after this example). Delivery is per offer.
found, err := client.Discover(ctx, &forav1.ResourceQuery{ Exchange: "exchange.example", Uris: []string{ "https://publisher.example/ai-infrastructure", "https://publisher.example/gpu-shortage-2026", "https://publisher.example/quantum-computing", },})if err != nil { return err}
// One group per requested URI, each carrying its verified offers and, for the// rest, the reason they were refused. A URI that yielded no offers at all is not// a rejection -- it appears in Groups with an absence reason.for _, group := range found.Groups { if len(group.Result.Verified) == 0 { continue } tx, err := client.Execute(ctx, group.Result.Verified[0]) if err != nil { return err } item := tx.GetItems()[0] if item.DenialReason != nil { continue // denied: no retrieval URL }
// Fetch presents the agent's proof of possession; the delivery edge checks the // URL and the proof. content, err := client.Fetch(ctx, item.GetRetrievalEndpoint()) if err != nil { return err } _ = content}Buying across Exchanges through a Broker
Section titled “Buying across Exchanges through a Broker”BrokerClient.Execute sends one BrokerService.ExecuteTransaction for offers issued by
any number of Exchanges. The client signs each offer’s acceptance and one request
acceptance over the whole set; the Broker re-packages the purchase into one sub-request
per Exchange and returns the results in request order. The client must sign as the
directory its requester’s domain names (WithSignatureAgent), because a Broker refuses
a request whose requester.domain is anything else, and the client refuses it locally
first. Each result item’s retrieval URL comes back exactly as the Exchange that issued
the item’s offer signed it, and the delivery edge verifies it when Fetch presents it.
broker := foraconnect.NewBrokerClient("https://broker.example", foraconnect.WithSigner(signer), foraconnect.WithSignatureAgent("https://agent.example"), foraconnect.WithRequester(&forav1.Requester{Id: "agent-1", Domain: "agent.example", Type: forav1.RequesterType_REQUESTER_TYPE_AGENT}),)resp, err := broker.Execute(ctx, selected) // []core.VerifiedOffer from several Exchangesif err != nil { return err // the Broker's own refusal: nothing was bought}for _, item := range resp.GetItems() { if r := item.GetRefusal(); r != nil { // That Exchange refused the whole sub-request; the other items are unaffected. log.Printf("%s refused %s: %s", r.GetParty(), item.GetOfferId(), r.GetCode()) continue } if item.DenialReason != nil { continue // a per-item denial from the Exchange } content, err := client.Fetch(ctx, item.GetRetrievalEndpoint()) if err != nil { return err } _ = content}// resp.GetTotals() holds the charged amount per currency, never summed across currencies.// An item refused with "unavailable" or "deadline_exceeded" may have been charged and is// not counted there; retry the same request with the same idempotency key to settle it.Error Handling
Section titled “Error Handling”All errors are typed. The caller can switch on error type for programmatic handling.
Error Types
Section titled “Error Types”Two error types cover every verb, so a caller branches in one place rather than switching over a family of per-failure structs.
// CallError -- every RPC verb (Discover, Execute, ReportUsage, Dispute, Register,// GetAccountStatus) fails with this one type.type CallError struct { Kind CallErrorKind // the class; branch on this Op string // the verb that failed Status int // HTTP status of the refusing answer; 0 when none arrived Header http.Header // that answer's header fields, when its status is 400 or above Reason string // the peer's own refusal token when it sent one Code connectrpc.Code // the Connect code of the peer's answer; zero when none Detail *forav1.ErrorDetail // the peer's typed reason when it sent one // PeerMessage is the developer message on the peer's typed reason. // NON-AUTHORITATIVE and unbounded -- branch on Kind or on the typed reason, // never on this text. PeerMessage string Err error}
// CallErrorKind values, from sdk/go/connect/callerror.go.const ( CallUnknown CallErrorKind = iota CallRefused // the server answered and said no CallUnreachable // the server did not answer CallNotSent // THIS SDK declined to send CallMalformed // the request could not be built or signed faithfully, // or the answer could not be read as the protocol defines it CallTooLarge // the response body was past the configured cap CallNotSignable // a signature or proof could not be produced)
// FetchError -- the delivery leg (client.Fetch) fails with this.type FetchError struct { Failure FetchFailure // FetchRefused, FetchUnreachable, FetchTooLarge, // FetchNotSignable, FetchMalformed Op string Status int // HTTP status when the edge answered, 0 otherwise Header http.Header // the header fields of the edge's error answer (400 or above) Reason string // the edge's own refusal token when it sent one Err error}Both carry ReasonOf(), which answers the peer’s own token when it sent one and the
failure class otherwise. Header holds what a refusal asks for next — a verifier’s
Accept-Signature, a gateway’s Retry-After, an edge’s discovery headers, which
helpers.ParseDiscoveryHint(err.Status, err.Header) reads — and is nil for any failure
that is not an error answer, a refused redirect included. When an interceptor passed to
connect.WithInterceptors retries a call, Status, Header and Code describe the last
request it sent. connect.ErrorDetailFrom(err) pulls the typed
ErrorDetail out of any error that carries one, including the retrieval_auth_failure
the client synthesizes from a delivery edge’s refusal token. resolvers.ErrEndpointRefused is a
sentinel reachable through errors.Is.
Error Recovery Strategy
Section titled “Error Recovery Strategy”| Failure | Recovery | User Impact |
|---|---|---|
CallNotSent | None — terminal. The SDK refused before anything left the process, so retrying unchanged fails the same way | Caller fixes the address, the key or the requester |
CallUnreachable | Retry with backoff. On a purchase, retry with the same idempotency key: the call may have completed, and the Exchange answers a repeat from its stored result | Transparent if the retry succeeds |
CallRefused | Depends on the typed reason — read it with connect.ErrorDetailFrom | A denial reason such as DENIAL_REASON_REPORTING_OVERDUE tells the caller what to fix. A request_auth_failure of SIGNATURE_STALE means sign the request again and retry; SIGNATURE_MISSING and SIGNATURE_INVALID are terminal until the signer is fixed |
CallMalformed | None — terminal. The request could not be built or signed faithfully, or the answer could not be read as the protocol defines it | Caller fixes the message |
CallTooLarge | Raise WithMaxContentBytes, or ask for less | Caller decides whether the body is legitimate |
CallNotSignable | None — terminal. No signature or proof could be produced | Caller fixes key custody or the signing window |
FetchRefused | Read Reason — the edge’s own token says whether the URL expired or the proof did not match | An expired delivery URL means buying again |
FetchUnreachable | Retry once; a signed URL may be CDN-transient | Transparent if the retry succeeds |
ErrEndpointRefused (errors.Is) | None — terminal. The Exchange answered and the answer is unusable: it advertises an endpoint on a host or port that did not serve its manifest, or one carrying userinfo. Retrying re-reads the same manifest | Content not available via this Exchange until its operator fixes fora.json |
State Management
Section titled “State Management”Exchange Registry
Section titled “Exchange Registry”- Storage: in-memory map + LRU eviction
- Key: provider domain (e.g. “techcrunch.com”)
- Value: list of authorized Exchange endpoints with trust level
- TTL: configurable, default 1 hour
- Capacity: LRU eviction at 10,000 domains (configurable)
- Cold start: first request to a new domain incurs fora.json fetch latency
Budget Tracker
Section titled “Budget Tracker”Three enforcement layers, checked in order:
| Layer | Scope | Storage | Lifecycle |
|---|---|---|---|
| Per-request | Single Fetch call | Comparison only (no state) | Instant |
| Per-session | Client instance lifetime | In-memory counter | Resets on NewClient |
| Per-period | Calendar period (e.g. 30 days) | Local file or Redis | Survives restarts |
Per-period persistence: by default, the SDK writes period budget state to a local JSON file (~/.fora/budget/<scope>.json). For multi-process agents, configure a shared Redis instance via Config.Budget.RedisURL.
Usage Reporter
Section titled “Usage Reporter”- Storage: in-memory bounded channel (default capacity: 1000)
- Background goroutine: drains channel, submits reports via ReportUsage RPC
- Deadline tracking: reports approaching deadline are prioritized
- Retry: failed submissions re-enqueued with exponential backoff (30s, 60s, 120s, max 10 min)
- Shutdown:
client.Close()blocks until all pending reports are submitted or context is cancelled - Overflow: if channel is full, the oldest report is dropped and a warning is logged
Testing Agent Code
Section titled “Testing Agent Code”No mock-Exchange package ships in any language, and the testutil helper the
deferred design pairs with the high-tier client does not exist. Writing an example
against it here would put an import on this page that nothing can resolve.
What the SDKs’ own suites do instead, and what works today: stand up a real
in-process origin and point the client at it. The Exchange side is a plain HTTP
server that serves /.well-known/fora.json, serves the Web Bot Auth directory,
answers the three RPC paths, and signs what it answers with the SDK’s own
primitives — sign_offer_jcs for the offer and sign_ed25519_signed_url for the
delivery URL in Python, helpers.SignOffer and helpers.SignURLEd25519 in
Go. Signing with the SDK rather than with a hand-written signature is what makes the
test prove an Exchange would have been accepted, instead of proving the calls did
not panic.
sdk/python/tests/exchange_harness.py is a worked example of exactly that, in under
300 lines, and it is what executes the Python README’s agent example on every run. Its
directory publishes the key it signs delivery URLs with, and each URL names that key by
thumbprint in kid, as the protocol says.
Testing a FORA service rather than an agent works the other way round, and the clients support it directly: a pre-signing hook, raw mode, strict decoding, an admin client and identity helpers. See Testing a FORA Service Through the SDK.