okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petapi: zstd responses, streams and request bodies; images served as raw bytes; drop redundant re-encodes7fd8cf5

main
10.6 KiB375 linesraw
1package daemon
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"mime"
12	"net"
13	"net/http"
14	"slices"
15	"strings"
16	"syscall"
17	"time"
18)
19
20// retryPolicy controls application recovery. Audited read-only GET requests
21// may also be recovered internally by net/http on a reused connection.
22type retryPolicy uint8
23
24const (
25	noRecovery retryPolicy = iota
26	readRecovery
27	authRecovery
28	receiptRecovery
29)
30
31type operation struct {
32	BuildRequest func(string, io.Reader) (*http.Request, error)
33	Handle       *OperationHandle
34	Body         any
35	Name         string
36	Method       string
37	Path         string
38	Policy       retryPolicy
39	Headers      http.Header
40	Validator    *string
41	Timeout      time.Duration
42	Capability   string
43}
44
45func (c *Connection) operationEndpoint(op operation) (ConnectionSnapshot, error) {
46	if c == nil {
47		return ConnectionSnapshot{}, errors.New("not connected to Albedo")
48	}
49	state := c.state.Load()
50	if state == nil {
51		return ConnectionSnapshot{}, errors.New("not connected to Albedo")
52	}
53	if op.Capability != "" {
54		if state.capabilities == nil {
55			return ConnectionSnapshot{}, &ProtocolError{Code: "not_attached", Operation: op.Name, Cause: errors.New("attach to the daemon before invoking optional APIs")}
56		}
57		if state.capabilities[op.Capability] < 1 {
58			return ConnectionSnapshot{}, &UpgradeRequiredError{Feature: "for " + op.Capability}
59		}
60	}
61	return state.endpoint, nil
62}
63
64func (c *Connection) capability(name string) int64 {
65	if state := c.state.Load(); state != nil {
66		return state.capabilities[name]
67	}
68	return 0
69}
70
71func newHTTPClient() *http.Client {
72	return &http.Client{
73		Transport:     codingTransport{base: &http.Transport{Proxy: http.ProxyFromEnvironment}},
74		CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
75	}
76}
77
78func isConnectionError(err error) bool {
79	if err == nil {
80		return false
81	}
82	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
83		return false
84	}
85	if errors.Is(err, io.EOF) ||
86		errors.Is(err, syscall.ECONNREFUSED) ||
87		errors.Is(err, syscall.ECONNRESET) ||
88		errors.Is(err, syscall.EPIPE) ||
89		errors.Is(err, net.ErrClosed) {
90		return true
91	}
92	if _, ok := errors.AsType[*net.OpError](err); ok {
93		return true
94	}
95	msg := err.Error()
96	return strings.Contains(msg, "connection refused") ||
97		strings.Contains(msg, "connection reset") ||
98		strings.Contains(msg, "broken pipe") ||
99		strings.Contains(msg, "EOF")
100}
101
102// responseLimits preserves each endpoint's accepted status and bounded reads.
103// A zero successStatus accepts any 2xx response.
104type responseLimits struct {
105	status          *int
106	successStatuses []int
107	successStatus   int
108	bodyBytes       int64
109	errorBytes      int64
110}
111
112// operationRequest binds the address and credentials to one immutable snapshot.
113func operationRequest(ctx context.Context, snapshot ConnectionSnapshot, operation operation, payload []byte) (*http.Request, error) {
114	var reader io.Reader
115	if payload != nil {
116		reader = bytes.NewReader(payload)
117	}
118	base := fmt.Sprintf("http://127.0.0.1:%d/", snapshot.Port)
119	var req *http.Request
120	var err error
121	if operation.BuildRequest != nil {
122		req, err = operation.BuildRequest(base, reader)
123		if err == nil {
124			req = req.WithContext(ctx)
125		}
126	} else {
127		req, err = http.NewRequestWithContext(ctx, operation.Method, strings.TrimSuffix(base, "/")+operation.Path, reader)
128	}
129	if err != nil {
130		return nil, err
131	}
132	if req.Header.Get("Content-Type") == "" {
133		req.Header.Set("Content-Type", "application/json")
134	}
135	req.Header.Set("Accept", "application/json")
136	for name, values := range operation.Headers {
137		req.Header[name] = slices.Clone(values)
138	}
139	if snapshot.Token != "" {
140		req.Header.Set("Authorization", "Bearer "+snapshot.Token)
141	}
142	// A mutation must never become replayable through net/http's pooled-connection retry.
143	// Explicit application recovery constructs a fresh request from payload instead.
144	if operation.Policy != readRecovery {
145		req.GetBody = nil
146	}
147	return req, nil
148}
149
150func encodeOperation(operation operation) ([]byte, error) {
151	if operation.Body == nil {
152		return nil, nil
153	}
154	return json.Marshal(operation.Body)
155}
156
157func recoverOperation(ctx context.Context, conn *Connection, operation operation, snapshot ConnectionSnapshot, failure error) (bool, error) {
158	if operation.Policy == noRecovery || ctx.Err() != nil {
159		return false, nil
160	}
161	apiError, rejected := errors.AsType[*APIError](failure)
162	authRefusal := rejected && apiError.StatusCode == http.StatusUnauthorized && apiError.Code == "authentication_required"
163	if !authRefusal && (operation.Policy != readRecovery || !isConnectionError(failure)) {
164		return false, nil
165	}
166	if conn.rediscover == nil {
167		return false, nil
168	}
169	if err := conn.Refresh(ctx); err != nil {
170		return false, err
171	}
172	latest := conn.Snapshot()
173	return !authRefusal || latest.Port != snapshot.Port || latest.Token != snapshot.Token, nil
174}
175
176func uncertainOperation(operation operation, failure error) error {
177	if operation.Policy == readRecovery || operation.Method == http.MethodGet {
178		return failure
179	}
180	return &UncertainOutcomeError{Operation: operation.Name, Cause: failure, Handle: operation.Handle}
181}
182
183// requestBytes owns every response until its bounded body has been read.
184func requestBytes(ctx context.Context, conn *Connection, operation operation, limits responseLimits) ([]byte, error) {
185	payload, err := encodeOperation(operation)
186	if err != nil {
187		return nil, err
188	}
189	for attempt := range 2 {
190		if canceled := ctx.Err(); canceled != nil {
191			return nil, canceled
192		}
193		snapshot, err := conn.operationEndpoint(operation)
194		if err != nil {
195			return nil, err
196		}
197		sent, encoding := requestBody(conn, payload)
198		req, err := operationRequest(ctx, snapshot, operation, sent)
199		if err != nil {
200			return nil, err
201		}
202		if encoding != "" {
203			req.Header.Set("Content-Encoding", encoding)
204		}
205		if canceled := ctx.Err(); canceled != nil {
206			return nil, canceled
207		}
208		operation.Method = req.Method
209		res, err := conn.HTTPClient().Do(req)
210		if err != nil {
211			if attempt == 0 {
212				recovered, recoveryErr := recoverOperation(ctx, conn, operation, snapshot, err)
213				if recoveryErr != nil {
214					return nil, errors.Join(err, recoveryErr)
215				}
216				if recovered {
217					continue
218				}
219			}
220			if canceled := ctx.Err(); canceled != nil {
221				err = errors.Join(err, canceled)
222			}
223			return nil, uncertainOperation(operation, err)
224		}
225		success := (limits.successStatus == 0 && res.StatusCode >= 200 && res.StatusCode < 300) || res.StatusCode == limits.successStatus
226		if limits.status != nil {
227			*limits.status = res.StatusCode
228		}
229		if len(limits.successStatuses) > 0 {
230			success = false
231			for _, expected := range limits.successStatuses {
232				if res.StatusCode == expected {
233					success = true
234				}
235			}
236		}
237		var body []byte
238		if success {
239			if operation.Validator != nil {
240				*operation.Validator = res.Header.Get("ETag")
241			}
242			body, err = readBounded(res.Body, limits.bodyBytes)
243			if err != nil && operation.Policy != readRecovery {
244				err = &ProtocolError{Code: "invalid_response", Operation: operation.Name, Cause: err}
245			}
246		} else {
247			if res.StatusCode >= 200 && res.StatusCode < 300 && operation.Policy != readRecovery {
248				err = invalidResponse(operation, "status", fmt.Errorf("unexpected HTTP status %d", res.StatusCode))
249			} else {
250				err = readHTTPError(res, limits.errorBytes)
251			}
252		}
253		_ = res.Body.Close()
254		if err == nil {
255			return body, nil
256		}
257		if attempt == 0 {
258			recovered, recoveryErr := recoverOperation(ctx, conn, operation, snapshot, err)
259			if recoveryErr != nil {
260				return nil, errors.Join(err, recoveryErr)
261			}
262			if recovered {
263				continue
264			}
265		}
266		if canceled := ctx.Err(); canceled != nil {
267			err = errors.Join(err, canceled)
268		}
269		if success || res.StatusCode >= 500 {
270			return nil, uncertainOperation(operation, err)
271		}
272		return nil, err
273	}
274	panic("unreachable retry budget")
275}
276
277type streamLimits struct {
278	requireSSE bool
279	lineBytes  int
280	errorBytes int64
281}
282
283// Recovery ends as soon as a successful stream is accepted; events are never replayed here.
284func scanEventStream(ctx context.Context, conn *Connection, operation operation, limits streamLimits, consume func(*bufio.Scanner) error) error {
285	for attempt := range 2 {
286		if err := ctx.Err(); err != nil {
287			return err
288		}
289		snapshot, err := conn.operationEndpoint(operation)
290		if err != nil {
291			return err
292		}
293		req, err := operationRequest(ctx, snapshot, operation, nil)
294		if err != nil {
295			return err
296		}
297		req.Header.Set("Accept", "text/event-stream")
298		operation.Method = req.Method
299		res, err := conn.HTTPClient().Do(req)
300		if err != nil {
301			if attempt == 0 {
302				recovered, recoveryErr := recoverOperation(ctx, conn, operation, snapshot, err)
303				if recoveryErr != nil {
304					return errors.Join(err, recoveryErr)
305				}
306				if recovered {
307					continue
308				}
309			}
310			if canceled := ctx.Err(); canceled != nil {
311				err = errors.Join(err, canceled)
312			}
313			return err
314		}
315		if res.StatusCode != http.StatusOK {
316			if limits.errorBytes == 0 {
317				apiError := &APIError{StatusCode: res.StatusCode}
318				if res.StatusCode == http.StatusUnauthorized && strings.HasPrefix(res.Header.Get("WWW-Authenticate"), "Bearer") {
319					apiError.Code = "authentication_required"
320				}
321				err = apiError
322			} else {
323				err = readHTTPError(res, limits.errorBytes)
324			}
325			_ = res.Body.Close()
326			if attempt == 0 {
327				recovered, recoveryErr := recoverOperation(ctx, conn, operation, snapshot, err)
328				if recoveryErr != nil {
329					return errors.Join(err, recoveryErr)
330				}
331				if recovered {
332					continue
333				}
334			}
335			if canceled := ctx.Err(); canceled != nil {
336				err = errors.Join(err, canceled)
337			}
338			return err
339		}
340		if limits.requireSSE {
341			mediaType, _, parseErr := mime.ParseMediaType(res.Header.Get("Content-Type"))
342			if parseErr != nil || mediaType != "text/event-stream" {
343				_ = res.Body.Close()
344				return streamFailure(StreamProtocol, fmt.Errorf("invalid stream content type %q", res.Header.Get("Content-Type")))
345			}
346		}
347		defer res.Body.Close()
348		scanner := bufio.NewScanner(res.Body)
349		scanner.Buffer(make([]byte, 64*1024), limits.lineBytes)
350		if err := consume(scanner); err != nil {
351			return err
352		}
353		return scanner.Err()
354	}
355	panic("unreachable retry budget")
356}
357
358func readHTTPError(res *http.Response, limit int64) error {
359	body, err := readBounded(res.Body, limit)
360	if err != nil {
361		return &APIError{StatusCode: res.StatusCode, Cause: err}
362	}
363	return decodeAPIError(res.StatusCode, body)
364}
365
366func readBounded(r io.Reader, limit int64) ([]byte, error) {
367	data, err := io.ReadAll(io.LimitReader(r, limit+1))
368	if err != nil {
369		return nil, err
370	}
371	if int64(len(data)) > limit {
372		return nil, fmt.Errorf("response exceeds this client's read limit (%d bytes)", limit)
373	}
374	return data, nil
375}