github.com/storacha/go-ucanto@v0.7.2/client/retrieval/connection.go (about) 1 package retrieval 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "errors" 7 "fmt" 8 "hash" 9 "io" 10 "net/http" 11 "net/url" 12 13 "github.com/storacha/go-ucanto/client" 14 "github.com/storacha/go-ucanto/core/dag/blockstore" 15 "github.com/storacha/go-ucanto/core/delegation" 16 "github.com/storacha/go-ucanto/core/invocation" 17 "github.com/storacha/go-ucanto/core/ipld" 18 "github.com/storacha/go-ucanto/core/ipld/block" 19 "github.com/storacha/go-ucanto/core/ipld/codec/cbor" 20 "github.com/storacha/go-ucanto/core/ipld/codec/json" 21 ucansha256 "github.com/storacha/go-ucanto/core/ipld/hash/sha256" 22 "github.com/storacha/go-ucanto/core/message" 23 mdm "github.com/storacha/go-ucanto/core/message/datamodel" 24 rdm "github.com/storacha/go-ucanto/server/retrieval/datamodel" 25 "github.com/storacha/go-ucanto/transport" 26 "github.com/storacha/go-ucanto/transport/headercar" 27 hcmsg "github.com/storacha/go-ucanto/transport/headercar/message" 28 thttp "github.com/storacha/go-ucanto/transport/http" 29 "github.com/storacha/go-ucanto/ucan" 30 ) 31 32 // Option is an option configuring a retrieval connection. 33 type Option func(cfg *config) 34 35 type config struct { 36 client *http.Client 37 headers http.Header 38 } 39 40 // WithClient configures the HTTP client the connection should use to make 41 // requests. 42 func WithClient(c *http.Client) Option { 43 return func(cfg *config) { 44 cfg.client = c 45 } 46 } 47 48 // WithHeaders configures additional HTTP headers to send with requests. 49 func WithHeaders(h http.Header) Option { 50 return func(cfg *config) { 51 cfg.headers = h 52 } 53 } 54 55 // NewConnection creates a new connection to a retrieval server that uses the 56 // headercar transport. 57 func NewConnection(id ucan.Principal, url *url.URL, opts ...Option) (*Connection, error) { 58 cfg := config{} 59 for _, o := range opts { 60 o(&cfg) 61 } 62 63 hasher := sha256.New 64 channel := thttp.NewChannel( 65 url, 66 thttp.WithMethod("GET"), 67 thttp.WithSuccessStatusCode( 68 http.StatusOK, 69 http.StatusPartialContent, 70 http.StatusNotExtended, // indicates further proof must be supplied 71 http.StatusRequestHeaderFieldsTooLarge, // indicates invocation is too large to fit in headers 72 ), 73 thttp.WithClient(cfg.client), 74 thttp.WithHeaders(cfg.headers), 75 ) 76 codec := headercar.NewOutboundCodec() 77 return &Connection{id, channel, codec, hasher}, nil 78 } 79 80 type Connection struct { 81 id ucan.Principal 82 channel transport.Channel 83 codec transport.OutboundCodec 84 hasher func() hash.Hash 85 } 86 87 var _ client.Connection = (*Connection)(nil) 88 89 func (c *Connection) ID() ucan.Principal { 90 return c.id 91 } 92 93 func (c *Connection) Codec() transport.OutboundCodec { 94 return c.codec 95 } 96 97 func (c *Connection) Channel() transport.Channel { 98 return c.channel 99 } 100 101 func (c *Connection) Hasher() hash.Hash { 102 return c.hasher() 103 } 104 105 // Execute performs a UCAN invocation using the headercar transport, 106 // implementing a "probe and retry" pattern to handle HTTP header size 107 // limitations when the invocation is too large to fit. 108 // 109 // The method first attempts to send the complete invocation (including all 110 // proofs) in HTTP headers. If this fails due to size constraints (typically 4KB 111 // header limit), it falls back to a multipart negotiation protocol: 112 // 113 // 1. Send invocation with ALL proofs omitted 114 // 2. Server responds with 510 (Not Extended) listing missing proof CID(s) 115 // 3. Send partial invocations with each missing proof attached one by one 116 // 4. Repeat until server has all required proofs (200/206 response) 117 // 118 // This approach optimizes for the common case (shallow delegation chains that 119 // fit in headers) while also handling deep proof chains that require 120 // multiple round trips. The server caches proofs between requests, so each 121 // proof only needs to be sent once per session. 122 // 123 // Note: The current implementation processes missing proofs sequentially rather 124 // than in batches, which means deep delegation chains will result in multiple 125 // HTTP round trips. This trade-off prioritizes implementation simplicity over 126 // network efficiency, which is acceptable given current delegation chain depths 127 // but may need optimization as authorization hierarchies grow deeper. 128 // 129 // Returns the execution response, the final HTTP response, and any error 130 // encountered. 131 func Execute(ctx context.Context, inv invocation.Invocation, conn client.Connection) (client.ExecutionResponse, transport.HTTPResponse, error) { 132 input, err := message.Build([]invocation.Invocation{inv}, nil) 133 if err != nil { 134 return nil, nil, fmt.Errorf("building message: %w", err) 135 } 136 137 var response transport.HTTPResponse 138 multi := false 139 140 req, err := conn.Codec().Encode(input) 141 if err != nil { 142 if errors.Is(err, hcmsg.ErrHeaderTooLarge) { 143 multi = true 144 } else { 145 return nil, nil, fmt.Errorf("encoding message: %w", err) 146 } 147 } else { 148 response, err = conn.Channel().Request(ctx, req) 149 if err != nil { 150 return nil, nil, fmt.Errorf("sending message: %w", err) 151 } 152 153 if response.Status() == http.StatusRequestHeaderFieldsTooLarge { 154 multi = true 155 err := response.Body().Close() // we don't need this anymore 156 if err != nil { 157 return nil, nil, fmt.Errorf("closing response body: %w", err) 158 } 159 } 160 } 161 162 // if the header fields are too big, we need to split the delegation into 163 // multiple requests... 164 if multi { 165 response, err = sendPartialInvocations(ctx, inv, conn) 166 if err != nil { 167 return nil, nil, fmt.Errorf("sending partial invocations: %w", err) 168 } 169 } 170 171 output, err := conn.Codec().Decode(response) 172 if err != nil { 173 return nil, nil, fmt.Errorf("decoding message: %w", err) 174 } 175 176 return client.ExecutionResponse(output), response, nil 177 } 178 179 func sendPartialInvocations(ctx context.Context, inv invocation.Invocation, conn client.Connection) (transport.HTTPResponse, error) { 180 br, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(inv.Export())) 181 if err != nil { 182 return nil, fmt.Errorf("reading invocation blocks: %w", err) 183 } 184 part, err := omitProofs(inv) 185 if err != nil { 186 return nil, fmt.Errorf("creating invocation %s with omitted proofs: %w", inv.Link().String(), err) 187 } 188 189 parts := map[string]delegation.Delegation{} 190 prfs := inv.Proofs() 191 for len(prfs) > 0 { 192 root := prfs[0] 193 prfs = prfs[1:] 194 prf, err := delegation.NewDelegationView(root, br) 195 if err != nil { 196 return nil, fmt.Errorf("creating delegation: %w", err) 197 } 198 prfs = append(prfs, prf.Proofs()...) 199 // now export without proofs 200 prf, err = omitProofs(prf) 201 if err != nil { 202 return nil, fmt.Errorf("creating delegation %s with omitted proofs: %w", prf.Link().String(), err) 203 } 204 parts[prf.Link().String()] = prf 205 } 206 // we already tried this 207 if len(parts) == 0 { 208 return nil, errors.New("invocation is too big to send in HTTP headers") 209 } 210 211 // now send the parts 212 for { 213 select { 214 case <-ctx.Done(): 215 return nil, ctx.Err() 216 default: 217 } 218 219 input, err := newPartialInvocationMessage(inv.Link(), part) 220 if err != nil { 221 return nil, fmt.Errorf("building message: %w", err) 222 } 223 224 req, err := conn.Codec().Encode(input) 225 if err != nil { 226 return nil, fmt.Errorf("encoding message: %w", err) 227 } 228 229 res, err := conn.Channel().Request(ctx, req) 230 if err != nil { 231 return nil, fmt.Errorf("sending message: %w", err) 232 } 233 234 if res.Status() == http.StatusPartialContent || res.Status() == http.StatusOK { 235 return res, nil 236 } 237 238 // if still too big, then fail 239 if res.Status() == http.StatusRequestHeaderFieldsTooLarge { 240 return nil, errors.New("invocation is too big to send in HTTP headers") 241 } 242 243 if res.Status() != http.StatusNotExtended { 244 return nil, fmt.Errorf("unexpected status code: %d", res.Status()) 245 } 246 247 bodyReader := res.Body() 248 body, err := io.ReadAll(bodyReader) 249 if err != nil { 250 bodyReader.Close() 251 return nil, fmt.Errorf("reading not extended body: %w", err) 252 } 253 if err = bodyReader.Close(); err != nil { 254 return nil, fmt.Errorf("closing response body: %w", err) 255 } 256 257 var model rdm.MissingProofsModel 258 err = json.Decode(body, &model, rdm.MissingProofsType()) 259 if err != nil { 260 return nil, fmt.Errorf("decoding body: %w", err) 261 } 262 if len(model.Proofs) == 0 { 263 return nil, errors.New("server did not include missing proofs in response") 264 } 265 266 p, ok := parts[model.Proofs[0].String()] 267 if !ok { 268 return nil, fmt.Errorf("missing proof not found or was already sent: %s", model.Proofs[0].String()) 269 } 270 part = p 271 delete(parts, p.Link().String()) 272 } 273 } 274 275 func omitProofs(dlg delegation.Delegation) (delegation.Delegation, error) { 276 blocks := dlg.Export(delegation.WithOmitProof(dlg.Proofs()...)) 277 bs, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(blocks)) 278 if err != nil { 279 return nil, err 280 } 281 return delegation.NewDelegation(dlg.Root(), bs) 282 } 283 284 func newPartialInvocationMessage(invocation ipld.Link, part delegation.Delegation) (message.AgentMessage, error) { 285 bs, err := blockstore.NewBlockStore(blockstore.WithBlocksIterator(part.Export())) 286 if err != nil { 287 return nil, err 288 } 289 msg := mdm.AgentMessageModel{ 290 UcantoMessage7: &mdm.DataModel{ 291 Execute: []ipld.Link{invocation}, 292 }, 293 } 294 rt, err := block.Encode( 295 &msg, 296 mdm.Type(), 297 cbor.Codec, 298 ucansha256.Hasher, 299 ) 300 if err != nil { 301 return nil, err 302 } 303 err = bs.Put(rt) 304 if err != nil { 305 return nil, err 306 } 307 return message.NewMessage(rt.Link(), bs) 308 }