Skip to content

feat!: retrieval server and client#48

Merged
alanshaw merged 28 commits into
mainfrom
feat/headercar-transport-codec
Sep 11, 2025
Merged

feat!: retrieval server and client#48
alanshaw merged 28 commits into
mainfrom
feat/headercar-transport-codec

Conversation

@alanshaw

@alanshaw alanshaw commented Jun 17, 2025

Copy link
Copy Markdown
Member

This PR implements the RFC here. It exposes a server and client implementation that allows UCAN authorized retrieval requests via invocations (and receipts) passed in HTTP headers. This leaves the HTTP response body available to be used for retrieved bytes.

A retrieval server is very similar to a normal Ucanto server, except it requires invocations to be sent using the headercar transport codec. The only other difference is that invocation handlers receive an extra argument - the HTTP request info, and can return and additional value - a HTTP response.

The retrieval client is also very similar to a Ucanto client, except that it has the ability to send an invocation in multiple parts, if it does not fit in HTTP headers. Essentially it'll send proofs one by one until the server has all the proofs required to execute the invocation. The server has an LRU cache allowing for this.

The PR also includes a transport codec that encodes agent messages into HTTP headers.

🎬 Demo: https://youtu.be/11np-cGTe48?si=kw88R1DAlMSq-b1T

resolves #59

@codecov

codecov Bot commented Jun 17, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 18.16143% with 730 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
server/retrieval/server.go 0.00% 273 Missing ⚠️
client/retrieval/connnection.go 47.90% 62 Missing and 25 partials ⚠️
testing/helpers/printer/printer.go 0.00% 72 Missing ⚠️
server/retrieval/options.go 0.00% 58 Missing ⚠️
core/message/message.go 0.00% 49 Missing ⚠️
transport/http/channel.go 0.00% 36 Missing ⚠️
server/retrieval/error.go 0.00% 27 Missing ⚠️
transport/headercar/codec.go 0.00% 26 Missing ⚠️
transport/headercar/message/header.go 53.70% 17 Missing and 8 partials ⚠️
transport/headercar/request/request.go 0.00% 14 Missing ⚠️
... and 11 more
Files with missing lines Coverage Δ
core/car/car.go 58.02% <100.00%> (ø)
core/delegation/delegation.go 45.74% <100.00%> (+3.69%) ⬆️
core/ipld/hash/sha256/sha256.go 0.00% <ø> (ø)
core/receipt/ran/ran.go 0.00% <ø> (ø)
server/server.go 73.85% <100.00%> (ø)
client/connection.go 0.00% <0.00%> (ø)
testing/helpers/helpers.go 0.00% <0.00%> (ø)
transport/car/codec.go 0.00% <0.00%> (ø)
server/retrieval/cache.go 80.00% <80.00%> (ø)
core/receipt/receipt.go 56.77% <71.42%> (+0.14%) ⬆️
... and 16 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@alanshaw alanshaw force-pushed the feat/headercar-transport-codec branch from 044368c to d303891 Compare June 17, 2025 15:51
@alanshaw alanshaw marked this pull request as ready for review June 25, 2025 14:34
@alanshaw alanshaw requested review from a team, frrist and hannahhoward June 25, 2025 18:05
@alanshaw alanshaw requested a review from volmedo as a code owner July 4, 2025 10:36
@alanshaw

alanshaw commented Aug 6, 2025

Copy link
Copy Markdown
Member Author

Note to self: need to set caching headers to expire when delegation expires + Vary on X-Agent-Message

@alanshaw alanshaw force-pushed the feat/headercar-transport-codec branch from 8544963 to af2d42d Compare August 12, 2025 19:45
@alanshaw alanshaw changed the title feat: header CAR transport codec feat: retrieval server and client Aug 18, 2025
@alanshaw alanshaw changed the title feat: retrieval server and client feat!: retrieval server and client Aug 18, 2025

@frrist frrist left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good stuff, there's a lot here!
Will give another look after some conversation in comments and related RFC.

Comment thread client/retrieval/client.go Outdated
Comment thread core/ipld/hash/sha256/sha256.go
Comment thread client/retrieval/connnection.go Outdated
return nil, nil, fmt.Errorf("decoding body: %w", err)
}
if len(model.Proofs) == 0 {
return nil, nil, fmt.Errorf("missing missing proofs: %w", err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe something like: "Server did not include missing proofs in response"?

return c.hasher()
}

func Execute(ctx context.Context, inv invocation.Invocation, conn client.Connection) (client.ExecutionResponse, transport.HTTPResponse, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: preference for this method to be broken down a bit more. Ideaally separate functions for "regular" requests and "multipart" requests at a minimum.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, I'd appreciate a comment on the top of this method describing the flow of things, something like:

Execute performs a UCAN invocation using the headercar transport, implementing
a "probe and retry" pattern to handle HTTP header size limitations.

The method first attempts to send the complete invocation (including all proofs)
in HTTP headers. If this fails due to size constraints (4KB header limit), it
falls back to a multipart negotiation protocol:

1. Send invocation with ALL proofs omitted
2. Server responds with 510 (Not Extended) listing missing proof CIDs
3. Send partial invocations with each missing proof attached one by one as requested (TODO I probably have this wrong)
4. Repeat until server has all required proofs (200/206 response)

This approach optimizes for the common case (shallow delegation chains that fit
in headers) while also handling deep proof chains that require
multiple round trips. The server caches proofs between requests, so each proof
only needs to be sent once per session.

Note: The current implementation processes missing proofs sequentially rather
than in batches, which means deep delegation chains will result in multiple
HTTP round trips. This trade-off prioritizes implementation simplicity over
network efficiency, which is acceptable given current delegation chain depths
but may need optimization as authorization hierarchies grow deeper.

Returns the execution response, the final HTTP response, and any error encountered.

Based on a read of the implementation, I think my comment here: https://github.com/storacha/specs/pull/139/files#r2304572067 is probably an incorrect understanding. Though this does motivate, to me at least, a separate endpoint on nodes where proofs can be Put once before a Get, though I haven't really thought this through completely, so I might be speaking non-sense 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 will do. Please check my response https://github.com/storacha/specs/pull/139/files#r2334017925

Comment thread core/car/car.go
}.Sum(bytes)
return cidlink.Link{Cid: c}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might want to replace this package with https://github.com/storacha/go-libstoracha/blob/main/testutil in the future.

@alanshaw alanshaw Sep 10, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That ends up being circular I think...

Comment thread transport/car/request/request.go Outdated
return nil, fmt.Errorf("decoding CAR: %w", err)
}
if len(roots) != 1 {
return nil, fmt.Errorf("unexpected number of roots: %d", len(roots))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: include the expected number of roots in the error message, i.e. 1

Comment on lines +29 to +31
if len(roots) != 1 {
return nil, fmt.Errorf("unexpected number of roots: %d", len(roots))
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

Comment thread transport/headercar/message/header.go
Comment thread transport/headercar/message/header.go Outdated
Comment on lines +51 to +63
r, w := io.Pipe()
go func() {
gz := gzip.NewWriter(w)
_, err := io.Copy(gz, data)
gz.Close()
w.CloseWithError(err)
}()

var b bytes.Buffer
_, err := b.ReadFrom(r)
if err != nil {
return "", fmt.Errorf("reading encoded CAR: %w", err)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove this go routine, maybe something like this?

	var b bytes.Buffer
	gz := gzip.NewWriter(&b)
	_, err := io.Copy(gz, data)
	if err != nil {
		gz.Close()
		return "", fmt.Errorf("compressing CAR data: %w", err)
	}
	if err := gz.Close(); err != nil {
		return "", fmt.Errorf("closing gzip writer: %w", err)
	}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh yes much nicer.

This comment was marked as resolved.

Co-authored-by: Forrest <forrest@storacha.network>
Comment thread server/retrieval/cache.go

@frrist frrist left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anything in here that causes me to want to block a merge. I'd like the Execute method to be broken into smaller methods, but not worth blocking on that point alone.
sooo LGTM 🚢 🚂 good stuff!

(though, it would probably be good to have a second set of eyes/approval - your call ofc)

@alanshaw

Copy link
Copy Markdown
Member Author

@frrist feedback addressed. I have also added an upper bound to the number of requests that will be made, if you have a delegation chain longer than 50 then you cannot use this code...

Let me know if you have any issues with that and I'll address in a new PR.

Comment on lines +157 to +171
// if the header fields are too big, we need to split the delegation into
// multiple requests...
if multi {
response, err = sendPartialInvocations(ctx, inv, conn)
if err != nil {
return nil, nil, fmt.Errorf("sending partial invocations: %w", err)
}
}

output, err := conn.Codec().Decode(response)
if err != nil {
return nil, nil, fmt.Errorf("decoding message: %w", err)
}

return client.ExecutionResponse(output), response, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is way better! Much easier to follow chain of execution logic 🫶

@alanshaw alanshaw merged commit 5cf60dc into main Sep 11, 2025
1 of 2 checks passed
@alanshaw alanshaw deleted the feat/headercar-transport-codec branch September 11, 2025 15:03
return nil, fmt.Errorf("unexpected status code: %d", res.Status())
}

body, err := io.ReadAll(res.Body())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to close the res.Body()?

}

// now send the parts
for range MaxPartialInvocationReqs {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a future version, we could do something like:

for {
  select {
    case ctx.Done():
      return ctx.Err()
    default:
      // continue with existing logic
  }
  // existing logic
}

This would allow the caller to decide how long they want to let this request run; MaxPartialInvocationReqs seems pragmatic enough for now.

@frrist

frrist commented Sep 11, 2025

Copy link
Copy Markdown
Member

I have also added an upper bound to the number of requests that will be made, if you have a delegation chain longer than 50 then you cannot use this code

SGTM, I suspect this case will be very unlikely, but proposed an alternative in the comments.

My only concern is this: #48 (comment) - it looks like we may have a resource leak by not closing the body, but unsure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support HTTP GET invocations

2 participants