client

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 2, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package client is a typed HTTP client for the Readur server API.

Index

Constants

View Source
const (
	MaxRetries = 3
	MinBackoff = 1 * time.Second
	MaxBackoff = 10 * time.Second
)

Retry constants enforce the budget from spec.md Q2:

  • 4 total attempts (1 initial + 3 retries)
  • exponential backoff with jitter, min 1 s, max 10 s between attempts
  • on 429, wait max(exponential_backoff, Retry-After)

Variables

View Source
var UploadAttempts atomic.Int64

UploadAttempts is incremented each time a body stream is (re)opened. Tests read it to verify retry behavior re-opens the file.

Functions

func Backoff

func Backoff(minWait, maxWait time.Duration, attemptNumber int, resp *http.Response) time.Duration

Backoff selects the duration to wait between attempts. It honors Retry-After on 429 (and, by extension, on 503), taking the maximum of the exponential-backoff-with-jitter value and the server-hinted delay. Jitter is ±25% of the exponential value.

func CheckRetry

func CheckRetry(ctx context.Context, resp *http.Response, err error) (bool, error)

CheckRetry is the retryablehttp.CheckRetry implementation for this client. It returns (shouldRetry, err) where a non-nil err halts the request immediately.

We retry when (a) the request's context is still alive, (b) the underlying error is in the retryable transport class, or (c) the HTTP status is in the retryable class (5xx, 408, 429).

func ClassifyStatus

func ClassifyStatus(code int, body string) error

ClassifyStatus converts an HTTP status + optional body into a CLIError with the appropriate exit code. Used after retries are exhausted.

func IsRetryableErr

func IsRetryableErr(err error) bool

IsRetryableErr reports whether a transport-level error is transient enough to justify a retry within the per-request budget.

Retryable classes:

  • connection reset by peer
  • broken pipe
  • unexpected EOF mid-response
  • DNS temporary errors
  • context deadline exceeded (when the user did not cancel)

Non-retryable classes (explicit):

  • x509 / TLS verification failures — the posture is intentional; retrying changes nothing
  • URL parse errors — a fix is a code change, not time

func IsRetryableStatus

func IsRetryableStatus(code int) bool

IsRetryableStatus reports whether an HTTP status is in the retryable class per the clarification in spec.md Q2/Q3: 5xx, 408, 429.

func NewRetryableClient

func NewRetryableClient() *retryablehttp.Client

NewRetryableClient returns a *retryablehttp.Client wired with the project's CheckRetry + Backoff + attempt budget. The returned client has Logger disabled by default (the CLI does its own structured logging); callers may override .Logger if needed.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client wraps retryablehttp with bearer injection, TLS posture control, and the mandatory per-request stderr warning when TLS verification is disabled (FR-016).

func NewClient

func NewClient(opts Options) *Client

NewClient constructs a Client honoring opts.InsecureSkipVerify.

func (*Client) Do

func (c *Client) Do(req *http.Request) (*http.Response, error)

Do issues a simple (non-body-replayable) request. Used for GETs and POSTs with small in-memory bodies.

func (*Client) DoJSON

func (c *Client) DoJSON(ctx context.Context, method, url string, body []byte) (*http.Response, error)

DoJSON is a convenience wrapper for POSTing a JSON body.

func (*Client) DoStreaming

func (c *Client) DoStreaming(
	ctx context.Context,
	method, url string,
	contentType string,
	body retryablehttp.ReaderFunc,
) (*http.Response, error)

DoStreaming issues a request whose body is produced by the provided ReaderFunc. The function is called fresh before every attempt so retries see a rewound body. The reader returned must be a fresh stream (e.g. re-open the underlying file).

func (*Client) ListLabels

func (c *Client) ListLabels(ctx context.Context) ([]Label, error)

ListLabels calls GET /api/labels and returns the server's labels. The method tolerates two wire shapes observed in the wild:

  1. `{"labels": [...]}` — the documented envelope
  2. `[...]` — a bare array, emitted by some deployments

func (*Client) Login

func (c *Client) Login(ctx context.Context, req LoginRequest) (*LoginResult, error)

Login POSTs the credentials to /api/auth/login and parses the response. The username in the result is taken from the server's echo when present, otherwise from the request — so callers can always trust `.Username`.

Login IS the rotation endpoint: a 401 here means the supplied credentials are wrong, never "token expired". Login therefore disables automatic rotation for its own DoJSON call so a bad password can never trigger a second login attempt.

func (*Client) ServerURL

func (c *Client) ServerURL() string

ServerURL returns the configured server URL.

func (*Client) SetDocumentLabels

func (c *Client) SetDocumentLabels(ctx context.Context, documentID string, labelIDs []string) error

SetDocumentLabels replaces the labels attached to a document.

The Readur upload endpoint (POST /api/documents) does not process a "labels" multipart field — label assignment is done with this separate call. Empty labelIDs is a no-op (no request issued), so the upload code path can call this unconditionally without an extra branch on its own.

func (*Client) URL

func (c *Client) URL(path string) string

URL builds the full URL for a relative API path (e.g. "/api/auth/login").

func (*Client) Upload

func (c *Client) Upload(ctx context.Context, params UploadParams) (*UploadResult, error)

Upload streams the file at params.LocalPath to POST /api/documents as multipart/form-data. Memory is O(1) in file size; the file is re-opened on every retry via retryablehttp.ReaderFunc.

Note on the path: the public Readur docs at docs.readur.app/api-reference/ describe POST /api/documents/upload, but real Readur deployments return 405 on that path (the /upload segment is parsed as a document id). The server's OPTIONS/Allow headers treat POST /api/documents as the create endpoint, and that is what this client uses.

type Label

type Label struct {
	ID            string    `json:"id"`
	Name          string    `json:"name"`
	Color         string    `json:"color,omitempty"`
	Description   string    `json:"description,omitempty"`
	DocumentCount int       `json:"document_count,omitempty"`
	CreatedBy     string    `json:"created_by,omitempty"`
	CreatedAt     time.Time `json:"created_at,omitzero"`
}

Label mirrors one entry in the Readur labels collection. Fields map to the JSON shape documented at docs.readur.app/api-reference/. Unknown fields are tolerated — json decoding ignores extras.

type LoginRequest

type LoginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

LoginRequest is the JSON body for POST /api/auth/login.

type LoginResult

type LoginResult struct {
	Token     string
	Username  string
	ExpiresAt time.Time // zero if the server did not return one
}

LoginResult is the subset of the server's response the CLI consumes. The server-side shape per docs:

{
  "token":      "<jwt>",
  "user":       {"username": "alice", ...},
  "expires_at": "2026-05-20T12:00:00Z"
}

type Options

type Options struct {
	ServerURL          string
	Token              string
	UserAgent          string
	InsecureSkipVerify bool
	// WarnOut receives the per-request TLS warning when insecure mode is
	// active. Typically wired to os.Stderr. If nil, warnings are dropped.
	WarnOut io.Writer
	// ProfileName is embedded in the TLS warning for clarity.
	ProfileName string

	// Username/Password are the saved credentials used by automatic
	// token rotation. Both must be non-empty for rotation to run.
	Username string
	Password string
	// TokenExpiry is the wall-clock expiry of the current Token, as
	// obtained at login. Proactive rotation fires when TokenExpiry is
	// past or within tokenExpirySkew of now. Zero disables proactive
	// rotation.
	TokenExpiry time.Time
	// OnTokenRotate is invoked after a successful rotation with the
	// freshly issued token and its expiry. The CLI wires this to
	// persist the new values back into the active profile. nil is
	// allowed — rotation still works; the caller just won't see it.
	OnTokenRotate func(newToken string, expiresAt time.Time)
}

Options configures a Client. Zero values are sensible.

When Username and Password are both set, the client will attempt to silently re-authenticate: proactively, if TokenExpiry is within the tokenExpirySkew window; reactively, once per request, on HTTP 401. A successful rotation calls OnTokenRotate so the caller (the CLI) can persist the refreshed token back to config.toml. See research.md §11.

type ServerResponseSummary

type ServerResponseSummary struct {
	DocumentID string // populated on successful upload (201)
	StatusCode int    // HTTP status or 0 if the request never reached the server
	ErrorBody  string // trimmed server error body on non-2xx
	Retryable  bool   // classifier output
}

ServerResponseSummary mirrors the authoritative outcome of a single HTTP call. Purely internal — not persisted.

type UploadParams

type UploadParams struct {
	LocalPath   string
	DisplayName string
	Title       *string
	OCREnabled  *bool
	Language    *string
}

UploadParams are the server-visible fields for a single-file upload.

Labels are intentionally absent: the upstream Readur upload handler (POST /api/documents) ignores any "labels" multipart field, and label assignment is done with a separate PUT /api/labels/documents/{id} call (see Client.SetDocumentLabels). The CLI's runUpload composes the two calls.

type UploadResult

type UploadResult struct {
	DocumentID string `json:"id"`
	Filename   string `json:"filename"`
}

UploadResult is the subset of the server response the CLI consumes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL