Files
Warp/server/internal/appservice/client.go
T
tajniak81andClaude Opus 5.5 487e0f410b
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 10: hardening and ops
Implements PLAN.md Phase 10.

Simplified Sliding Sync (MSC4186) is served at the unstable
org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists
are sorted by activity and filtered by DM, invite, encryption, room type,
space and tag; room subscriptions, required_state with wildcards, $ME and
$LAZY, and the to_device, e2ee, account_data, receipts and typing
extensions are supported. Per-connection state is kept in memory and an
unknown pos answers M_UNKNOWN_POS.

Threads and relations: m.relates_to is indexed on roomserver_events
(migration 0012), /relations (with recursion) and /threads are served, and
every event a timeline read returns carries its m.thread, m.replace and
m.reference summaries in unsigned.m.relations. Notifications carry a
thread ID (migration 0013): threaded receipts clear only their thread, and
sync reports unread_thread_notifications when the filter asks.

Spaces: /hierarchy walks a space breadth first, asking other servers about
rooms this one is not in through the new federation hierarchy endpoint;
room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase)
or tsvector (postgres), bounded to what each user may see, with context,
profiles, state, grouping and paging.

The PocketBase driver is real: the sqlite schema and store on an embedded
PocketBase app's database, with media in its filesystem, local or S3
(database.pocketbase.s3). It runs the full storage test suite.

Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans
per route, component spans, and trace propagation on federation, push and
application service calls. New metrics for persisted events, waiting
syncs, sliding sync connections and the database pool, with a Grafana
dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana,
Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports
request and delivery latencies.

The load tester found a long-standing bug, fixed here: on a server where
no account data had been written, every incremental /sync re-sent the push
rules and returned at once, and an all-zero token was taken for an initial
sync, so long polls became busy loops.

The PostgreSQL migrations and search dialect compile but were not run
against a live PostgreSQL in this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:21:31 +02:00

174 lines
5.5 KiB
Go

package appservice
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/dariuszjaniak/warp/server/internal/tracing"
)
// Errors of the Application Service API client. The ping endpoint reports
// each with its own Matrix error code.
var (
// ErrNoURL is a service registered without a URL, which cannot be
// sent anything.
ErrNoURL = errors.New("application service has no URL")
// ErrConnectionFailed means the service could not be reached.
ErrConnectionFailed = errors.New("could not connect to the application service")
// ErrConnectionTimeout means it did not answer in time.
ErrConnectionTimeout = errors.New("the application service did not answer in time")
)
// BadStatusError is an answer from the service that was not a success.
type BadStatusError struct {
Status int
// Body is the start of what the service answered, for whoever has to
// work out why.
Body string
}
func (e *BadStatusError) Error() string {
return fmt.Sprintf("the application service answered %d: %s", e.Status, e.Body)
}
// maxErrorBody caps how much of a failed answer is kept.
const maxErrorBody = 4 << 10
// Client speaks the Application Service API: the requests a homeserver
// makes to a service.
type Client struct {
http *http.Client
timeout time.Duration
}
// NewClient builds a client. The timeout bounds one request; transactions
// that time out are retried like any other failure.
func NewClient(timeout time.Duration) *Client {
return &Client{
http: &http.Client{
Transport: tracing.Transport(nil),
// A service is reached at the URL its registration names and
// nowhere else: a redirect would take the hs_token with it.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
timeout: timeout,
}
}
// transactionBody is PUT /_matrix/app/v1/transactions/{txnId}.
type transactionBody struct {
Events []json.RawMessage `json:"events"`
}
// SendTransaction delivers a batch of events. The same transaction ID is
// resent with the same events until the service accepts it, and the
// service deduplicates by ID.
func (c *Client) SendTransaction(ctx context.Context, svc *Service, txnID int64, events []json.RawMessage) error {
body, err := json.Marshal(transactionBody{Events: events})
if err != nil {
return err
}
_, err = c.do(ctx, svc, http.MethodPut, "/_matrix/app/v1/transactions/"+strconv.FormatInt(txnID, 10), body)
return err
}
// QueryUser asks a service whether a user in its namespace exists, which
// it answers by creating the user if it wants it to. It reports whether
// the service said yes.
func (c *Client) QueryUser(ctx context.Context, svc *Service, userID string) (bool, error) {
return c.query(ctx, svc, "/_matrix/app/v1/users/"+url.PathEscape(userID))
}
// QueryAlias asks a service whether an alias in its namespace exists,
// which it answers by creating the room and the alias.
func (c *Client) QueryAlias(ctx context.Context, svc *Service, alias string) (bool, error) {
return c.query(ctx, svc, "/_matrix/app/v1/rooms/"+url.PathEscape(alias))
}
// query makes a GET whose answer is yes (200) or no (404).
func (c *Client) query(ctx context.Context, svc *Service, path string) (bool, error) {
_, err := c.do(ctx, svc, http.MethodGet, path, nil)
var status *BadStatusError
switch {
case err == nil:
return true, nil
case errors.As(err, &status) && status.Status == http.StatusNotFound:
return false, nil
default:
return false, err
}
}
// Ping makes POST /_matrix/app/v1/ping and reports how long it took.
func (c *Client) Ping(ctx context.Context, svc *Service, transactionID string) (time.Duration, error) {
body, err := json.Marshal(map[string]string{"transaction_id": transactionID})
if transactionID == "" {
body, err = []byte(`{}`), nil
}
if err != nil {
return 0, err
}
start := time.Now()
if _, err := c.do(ctx, svc, http.MethodPost, "/_matrix/app/v1/ping", body); err != nil {
return 0, err
}
return time.Since(start), nil
}
// do makes one request to a service, authenticated with its hs_token.
func (c *Client) do(ctx context.Context, svc *Service, method, path string, body []byte) ([]byte, error) {
if svc.URL == "" {
return nil, ErrNoURL
}
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(svc.URL, "/")+path, reader)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
// The header form only. The spec deprecated ?access_token= for this in
// v1.4, and a token in a URL ends up in the service's access log.
req.Header.Set("Authorization", "Bearer "+svc.HSToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
return nil, fmt.Errorf("%w: %v", ErrConnectionTimeout, err)
}
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
return nil, &BadStatusError{Status: resp.StatusCode, Body: string(head)}
}
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
return out, nil
}