api

package
v0.1.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package api is the HTTP client this CLI uses to talk to the public /v1 API: capacity checks, the email + code login flow, deploy creation, the call that starts a build, the build-log event stream, and the call that gives a built deploy an address. It knows only pkg/wire's types; it never reshapes them into a second, "nicer" shape, and it never invents an endpoint the contract does not define.

The upload is deliberately NOT here. It goes to a link the server signed, at an origin this client holds no credential for and answers in a vocabulary the /v1 contract does not define, so it belongs to the sequence that owns the archive rather than to the client that speaks this API.

Construction

New resolves a base URL — an explicit argument, else CURIOUS_API_URL, else the compiled-in default — through an address guard before building anything: a base URL that is not https, and not http to a loopback host, is refused outright, because a typo'd environment variable must never send a bearer token over the wire in clear. See validateBaseURL's own doc comment for the guard itself.

Error mapping

Any non-2xx response decodes into an *APIError carrying the HTTP status, the wire.ErrorCode, the server's own message, and any Retry-After the response carried. A code this binary has never seen is not a parse failure — pkg/wire's contract is additive-only, so an unrecognised code is carried through with its message intact rather than replaced or swallowed. Only a body that is not the error envelope at all (an HTML page from a proxy, an empty or truncated body) falls back to a generic message; the raw body never reaches the terminal.

Retries

A request is retried, at most twice with a small backoff, only for connection-level failures and 5xx responses, and only for the two calls the wire contract documents as idempotent (Capacity, Waitlist). auth/start and auth/verify are never retried: the first spends part of a per-identity hourly send budget on every attempt, and the second consumes its code atomically, so a retry after an ambiguous timeout could report failure for a call that had already succeeded. The create is never retried because a repeat makes a second deploy record, the start is never retried because everything a caller is waiting for afterwards arrives on the event stream — see DeployStart — and the publish is never retried because whether it landed is the very question a retry would be asking, and asking twice cannot answer it.

The event stream is not a request in that sense

DeployEvents is the one call that does not go through the shared path, and it takes NO deadline of any kind. Every other call here is bounded end to end by the per-request timeout, which is right for a small request and a small answer and fatal for a connection held open for the whole of a build: a total deadline cannot express "is this making progress", and a server's keep-alive frames cannot extend one. What ends that request is the caller's context.

Index

Constants

View Source
const (
	DefaultConnectTimeout        = 10 * time.Second
	DefaultTLSHandshakeTimeout   = 10 * time.Second
	DefaultResponseHeaderTimeout = 30 * time.Second
)

DefaultConnectTimeout, DefaultTLSHandshakeTimeout and DefaultResponseHeaderTimeout bound the three phases before a response exists: dialling the server, the TLS handshake, and the server answering a request it was sent. They are PROVISIONAL: starting points chosen before anything was measured, to be re-set from measurement. Each is recorded, with the reason for its number, beside the stall windows the tests use, and that record reads these constants rather than restating them, so this is the one home of each number.

The TLS bound is the figure the standard library's own default transport uses, which a custom transport does not inherit; that transport dials with a longer bound, and this one is shorter on purpose, so a host that will never answer is reported as unreachable sooner. The header bound equals the per-request total, so no ordinary call is bounded more tightly than before; for the build log, which has no total, it is the bound on a server that accepts a connection and never answers.

Variables

View Source
var ErrUnusableResponse = errors.New("the server's answer cannot be used")

ErrUnusableResponse marks an answer the server was entitled to send and this client cannot act on. It is NOT a transport failure and must not be reported as one: the request arrived, the server answered, and the answer said something this build cannot use.

View Source
var Version = "dev"

Version is this binary's release version, folded into every request's User-Agent. cmd/curious's own `version` variable belongs to package main and is set at release time via -ldflags; it cannot be imported here without inverting the module's dependency direction, so this package carries its own copy instead — left at "dev" by a plain build, exactly like main's, and wired to the real value by whatever constructs the Client the shipped binary actually uses.

Functions

func CanonicalKey

func CanonicalKey(raw string) (string, error)

CanonicalKey returns the COMPARISON-ONLY canonical form of a base URL: lowercased scheme, lowercased host with any trailing dot removed, an EXPLICIT port even when it is the scheme's default, no trailing slash, and the path otherwise untouched.

It is not a wire base, and using it as one is a bug

The string this returns is for asking "are these two configured endpoints the same endpoint". It is deliberately NOT what the client dials: validateBaseURL produces that, it is unchanged by this function's existence, and it preserves the caller's own spelling because request semantics are not this function's business to move. A base that reaches the wire through here would carry a port the user never wrote, into an Authorization-bearing request, on the strength of a comparison helper. Two forms, two jobs, and the split is the point.

Why an explicit port

"https://api.example.com" and "https://api.example.com:443" are the same endpoint and differ by nine bytes. Adding the default rather than stripping an explicit one is the direction that cannot lose information: stripping would have to know that :443 is redundant under https and not under some other scheme, which is the same knowledge in the harder direction.

Why this is exported at all

A config file records the endpoint a token was issued against, so that a token issued against a local development server is never sent to production, or the reverse. That check is a comparison of two configured endpoints, and it lives in another package.

Left to a byte comparison it is wrong in ways nobody probes: "http://localhost:8080/" logs a user out of "http://localhost:8080", and so — measured, not supposed — does "http://LOCALHOST:8080", because validateBaseURL lowercases the host into a local variable for its own loopback test and returns the URL with the caller's case intact. Left to a second normaliser written next to the caller, the two drift, and the drift shows up as "login never sticks" rather than as a diff.

What it refuses

Anything it cannot canonicalise WITHOUT GUESSING: an unparseable URL, an opaque one, one with no host, or one whose scheme has no default port this function knows. A caller comparing two endpoints treats an error as "not the same endpoint", which is the safe direction — it costs a login, where the other direction spends a token against a server it was never issued for.

It deliberately does NOT apply the address guard. Refusing plaintext to a non-loopback host is a decision about what this client may DIAL, made once at construction; a stored value that today's guard would refuse still has to be comparable, or a tightened guard would strand a config it can no longer describe.

func ResolveBaseURL

func ResolveBaseURL(explicit string) string

ResolveBaseURL answers "which API endpoint is this run talking to", and it is the ONE place that question is answered. An explicit argument wins; an empty one falls back to CURIOUS_API_URL, and then to the compiled-in default.

It is exported because more than this package needs the answer. The stored credential records the endpoint it was issued against so a token from a local development server is never sent to production, and the code holding that file has to know what the effective endpoint IS before it can compare. Without this, that code would have to repeat the fallback chain — including a second copy of the default URL, which the compiled-in-hostname guard refuses outright, correctly: a URL a reader cannot find by grepping for one declaration is the shape a phone-home takes.

It performs NO validation. The result is the base URL a caller MEANT, which is exactly what a comparison needs even when it is one the address guard would refuse to dial — a stored endpoint that a tightened guard now rejects must still be comparable, or the guard would strand a config it can no longer describe. New applies the guard immediately afterwards; nothing else should assume it has been applied.

Types

type APIError

type APIError struct {
	Status  int
	Code    wire.ErrorCode
	Message string
	// RetryAfter is parsed from the response header whenever it is
	// present, whatever the code — see decodeAPIError's doc comment.
	// Zero means the header was absent.
	RetryAfter time.Duration
}

APIError is a non-2xx /v1 response, decoded once into a shape callers can branch on without re-parsing wire.ErrorResponse themselves.

func (*APIError) Error

func (e *APIError) Error() string

type Client

type Client struct {

	// Token is exported on purpose. internal/guard's fourth guard
	// forbids an UNEXPORTED struct field that can reach a ui.Secret,
	// because fmt cannot call a method on a value it reaches by
	// reflecting an unexported field — an unexported Secret field here
	// would print in full through %v on a Client handed to a log line by
	// accident, and ui.Secret's own methods could not stop it. Exporting
	// the field costs nothing beyond that: ui.Secret already redacts
	// itself through every formatting and marshalling path on its own.
	Token ui.Secret
	// contains filtered or unexported fields
}

Client is this CLI's handle onto the public /v1 API.

func New

func New(baseURL string, opts ...Option) (*Client, error)

New constructs a Client. An empty baseURL falls back to CURIOUS_API_URL, and then to the compiled-in default — through ResolveBaseURL, which is the same function anything else asking that question uses, so the two cannot drift. Every base URL — explicit, from the environment, or the default — passes through the address guard before this returns; see validateBaseURL.

func (*Client) AuthStart

AuthStart calls POST /v1/auth/start, the first step of the email + code login flow. NEVER retried: each call spends one of four sends allowed per identity in a rolling hour, so a silent retry would spend a third of that budget on one keystroke. The success body is empty by design — wire.AuthStartResponse's own doc comment explains why it carries nothing a caller could branch on: the endpoint answers identically whether or not a code was actually sent.

func (*Client) AuthVerify

AuthVerify calls POST /v1/auth/verify, the second step of the login flow, and returns the bearer token for every subsequent authenticated call. NEVER retried: the submitted code is consumed atomically, so a retry after an ambiguous timeout could report failure for a verify that had already succeeded and already spent the code.

func (*Client) Capacity

func (c *Client) Capacity(ctx context.Context) (*wire.CapacityResponse, error)

Capacity calls GET /v1/capacity: the client's step-zero check, before any account-creating work, so a user is never asked to do work that cannot land. Unauthenticated, and idempotent per the endpoint's own contract, so a transient failure is retried.

func (*Client) DeployCreate

DeployCreate calls POST /v1/deploys: the first authenticated call this client makes, and the one that turns a packed archive into a deploy record plus somewhere to send it.

NEVER RETRIED, and the mechanism is the flag rather than new machinery. A repeat is not idempotent — it creates a second deploy record and spends quota again — and a failure after the request left this process is indistinguishable from one before it, so the honest move is to stop and say a deploy may have been created. auth/start and auth/verify are the standing precedent for the same argument.

IT AUTHENTICATES PER CALL. The bearer token goes on this request and on nothing else, through withBearerToken; see that method for why the header is not set in the shared path.

req.Bytes is the PACKED archive's size. The server signs the presigned upload with it as an exact Content-Length, so a wrong number here gets every upload refused rather than warned about — which is why the number is taken from whoever measured the archive rather than measured a second time.

func (*Client) DeployEvents

func (c *Client) DeployEvents(ctx context.Context, deployID string) (io.ReadCloser, error)

DeployEvents opens GET /v1/deploys/{id}/events and returns the raw stream for the caller to read and close. The caller owns the reader from the moment this returns without an error; a call that returns an error returns no reader, so a deferred close is safe at every call site.

It deliberately does not go through do

Every other call in this package is a small request and a small answer bounded end to end by the client's own per-request timeout. This one is a connection held open for the whole of a build, and applying that timeout to it would kill any build quieter than the timeout — a TOTAL DEADLINE CANNOT EXPRESS "IS THIS MAKING PROGRESS", and the server's own keep-alive frames cannot rescue one, because keeping a connection alive does not extend a deadline that is counting anyway.

So there is no deadline on the stream as a whole. Getting it OPEN is bounded, phase by phase, by the transport: dialling, the TLS handshake, and the server answering each have their own bound, and a failure in any of them comes back as a StreamOpenError naming the phase. Once the response has arrived, what ends this request is the caller's context: the stream's own terminating event, a cancellation, or a liveness rule the caller applies to the bytes it is reading, which is where that rule can actually see the thing it is about.

It is not retried either, and for once that is not a decision this function has to make: a stream that ends early is reconnected by whoever is reading it, from the beginning, because that is the only place that knows how much has already been shown.

The bearer token goes on this request — the event stream is an authenticated call on this project's own API, unlike the upload, where the link somebody else signed IS the credential.

func (*Client) DeployPublish

func (c *Client) DeployPublish(ctx context.Context, deployID string) (*wire.DeployPublishResponse, error)

DeployPublish calls POST /v1/deploys/{id}/publish: the last call of a deploy, and the one that gives a built deploy an address.

NEVER RETRIED, and the reason is the same one the create carries rather than the start's. A failure after the request left this process is indistinguishable from one before it, and the two possible truths — the deploy has an address, or it has none — are exactly what a caller would be retrying to find out. Asking again cannot answer that question: a second call arriving after a first one succeeded is a second decision about a deploy this client has already been told nothing about.

The success body carries a subdomain LABEL and an expiry, and per the contract's own rule no client behaviour depends on either: they are shown to a person. The label is the half a client cannot derive, and assembling an address out of it belongs to whoever knows the domain, which is not this package.

It authenticates per call, like the create and the start.

func (*Client) DeployStart

func (c *Client) DeployStart(ctx context.Context, deployID string) (*wire.DeployStartResponse, error)

DeployStart calls POST /v1/deploys/{id}/start: the call that turns an uploaded archive into a running build.

NEVER RETRIED, and the reason is not that a repeat would be harmful. The endpoint is honest about one — a start on a deploy that is already building answers with that status rather than an error — so a retry is not wrong. It is still the wrong instinct to build in, because of where the information lives: once this call has returned, everything the user is waiting for arrives on the event stream, so a client that reacts to a timeout by asking again is asking the one surface that has nothing left to tell it. Reconnect to the stream instead.

The success body's Status INFORMS and never BRANCHES. The contract says so at the type, and it is contract rather than convention: the client takes the identical next action for every value the field can carry, so a switch on it is the defect and not the omission. It is rendered, and that is all.

It authenticates per call, like the create.

func (*Client) Transport

func (c *Client) Transport() *http.Transport

Transport returns the *http.Transport every request from this Client goes through — the same one New builds, Proxy field included. It is a read-only escape hatch for a caller with a genuine reason to extend the Transport (additional TLS trust material, a wrapped RoundTripper, connection-level instrumentation) without this package growing a bespoke setter for every such knob. It grants no more trust than any other exported method already does: nothing stops a caller from mutating the result unwisely, the same as with any pointer this package hands back.

func (*Client) Waitlist

Waitlist calls POST /v1/waitlist. Idempotent per the endpoint's own contract, so a transient failure is retried. The success body is empty by design (wire.WaitlistResponse) — decoded here, never branched on.

type Option

type Option func(*Client)

Option configures a Client built by New.

func WithConnectionBounds added in v0.1.6

func WithConnectionBounds(connect, tlsHandshake, responseHeaders time.Duration) Option

WithConnectionBounds overrides the three bounds on opening a connection: dialling, the TLS handshake, and waiting for the response headers. Each defaults to its Default constant above. A zero or negative value is refused by New, for the reason WithTimeout's is: to the transport, zero means no bound at all.

IT EXISTS FOR THE ROWS. A row proving that a server which never answers is refused by the header bound cannot wait thirty seconds to see it.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout overrides the default 30-second per-request timeout. A zero or negative duration is refused by New, at construction — the same "refuse once, up front" shape the address guard already follows — rather than accepted here and left to make every subsequent call fail immediately with a deadline-exceeded error, which is what a context.WithTimeout given a non-positive duration does.

func WithToken

func WithToken(t ui.Secret) Option

WithToken sets the bearer token this Client attaches to a call that takes one. The four unauthenticated calls do not — auth/verify RETURNS a token, it does not spend one — so this has no visible effect against them; what reads it is a call that asks for it, per call, through withBearerToken below.

type StreamOpenError added in v0.1.6

type StreamOpenError struct {
	Stage StreamStage
	Err   error
}

StreamOpenError is a build-log request that failed before any response arrived, carrying the phase it failed in. The message is the underlying network error's, which names the host and never the request.

func (*StreamOpenError) Error added in v0.1.6

func (e *StreamOpenError) Error() string

func (*StreamOpenError) Unwrap added in v0.1.6

func (e *StreamOpenError) Unwrap() error

type StreamStage added in v0.1.6

type StreamStage string

StreamStage is the phase of opening the build log that a failure happened in. Each has its own bound on the transport.

const (
	// StageConnect is dialling the server.
	StageConnect StreamStage = "connect"
	// StageTLS is the TLS handshake on an established connection.
	StageTLS StreamStage = "tls"
	// StageResponse is waiting for the server to answer a request that
	// was sent.
	StageResponse StreamStage = "response"
)

Jump to

Keyboard shortcuts

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