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  }