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