Skip to content

Fetch Flow

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 + X-Content-Rules header: extract Exchange endpoint
| - 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)
| - Self-select the Offer.terms[] term whose 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
|
| 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
| - 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, ...}
StepTarget (p50)Target (p99)Notes
Parse domain<1ms<1msString operation
Resolve Exchanges (cache hit)<1ms<1msIn-memory lookup
Resolve Exchanges (cache miss)50ms200msHTTP fetch of fora.json
Pre-flight budget check<1ms<1msIn-memory arithmetic
DiscoverResources RPC10ms50msPer NFR targets
Select best offer<1ms<1msIn-memory sort
Budget check (exact)<1ms<1msIn-memory arithmetic
ExecuteTransaction RPC20ms100msPer NFR targets
Record spend<1ms<1msIn-memory write
Fetch content20ms200msCDN-dependent
Enqueue report<1ms<1msChannel write
Total (cache hit)~55ms~355msCompetitive with normal web request

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)
  • 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.

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.

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 and delivery are then 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]
content, err := client.Fetch(ctx, item.GetRetrievalEndpoint())
if err != nil {
return err
}
_ = content
}

All errors are typed. The caller can switch on error type for programmatic handling.

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 when the peer answered, 0 otherwise
Reason string // the peer's own refusal token when it sent one
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
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
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. connect.ErrorDetailFrom(err) pulls the typed ErrorDetail out of any error that carries one. resolvers.ErrEndpointRefused is a sentinel reachable through errors.Is.

FailureRecoveryUser Impact
CallNotSentNone — terminal. The SDK refused before anything left the process, so retrying unchanged fails the same wayCaller fixes the address, the key or the requester
CallUnreachableRetry with backoffTransparent if the retry succeeds
CallRefusedDepends on the typed reason — read it with connect.ErrorDetailFromA denial reason such as DENIAL_REASON_REPORTING_OVERDUE tells the caller what to fix
CallMalformedNone — terminal. The request could not be built or signed faithfullyCaller fixes the message
CallTooLargeRaise WithMaxContentBytes, or ask for lessCaller decides whether the body is legitimate
CallNotSignableNone — terminal. No signature or proof could be producedCaller fixes key custody or the signing window
FetchRefusedRead Reason — the edge’s own token says whether the URL expired or the proof did not matchAn expired delivery URL means buying again
FetchUnreachableRetry once; a signed URL may be CDN-transientTransparent 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 manifestContent not available via this Exchange until its operator fixes fora.json
  • 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

Three enforcement layers, checked in order:

LayerScopeStorageLifecycle
Per-requestSingle Fetch callComparison only (no state)Instant
Per-sessionClient instance lifetimeIn-memory counterResets on NewClient
Per-periodCalendar period (e.g. 30 days)Local file or RedisSurvives 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.

  • 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

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.