Documentation
¶
Overview ¶
Package korbit is the wire layer (L0) plus the L1 primitive client for the Korbit Open API v2.
L0 — wire building blocks (no policy) ¶
- request building + ordered, insertion-order param encoding (encode.go),
- ED25519 signing over the exact sent bytes, signature appended last (sign.go),
- the {success,data} / {success,error} envelope unwrap into json.RawMessage or an *output.ApiError (client.go),
- the server-clock measurement primitive MeasureClockOffset + ClockOffset (timesync.go),
- the retry taxonomy: Classify → RetryClass, the backoff ladder, and the bounded ExecuteWithRetry loop (retry.go).
These carry no call policy: they do exactly what they are told once.
L1 — the primitive client (Client.Do) ¶
Client.Do(ctx, Call, Policy) is a contract-faithful transport: it signs via a shared clock, executes exactly the retry Policy it is HANDED, and reports every logical call to a per-call Recorder it mints itself. The same Client also signs the private WebSocket upgrade (SignHandshake), so it is the single front door for calling Korbit. Higher layers (L2 ops, frontends) own the policy and the journaling; the Client owns neither.
Contracts ¶
- Policy is INPUT. The zero Policy is a single shot — the fail-safe default, identical to the RetryPolicy zero value. Idempotent gates the full ladder; RetryPreExec retries only the provably-pre-execution classes.
- One Do == one Recorder.Record. NewRecorder mints a FRESH per-call Recorder for each Do (nil = not recorded); Do calls its Ready ONCE before the first send (an error aborts with nothing on the wire — preserving the open-journal-before-send hard guarantee) and Record ONCE after the call completes, with the attempt count folded in.
- A POST-call journal failure never returns through Do. The Recorder decides fail-vs-warn and delivers it through its OWN sink (callrec's onPostFailure): Record returns no error and Meta carries none, so the API error and the journal error stay on strictly separate channels. A frontend's sink can warn, toast, or capture-for-fatal after emitting the API result.
- Origin flows into CallInfo (the journal `origin` column is reserved, not written). ParamsJSON is PRE-SIGNING params only — the timestamp, recvWindow, and signature are added inside Build after the CallInfo is captured, so no secret or signing artifact ever reaches a Recorder.
- The User-Agent seam: Options.UserAgent / Client.UserAgent override the header; "" keeps the default ("korbit-cli/<version>"). COMPOSING the string (program version + OS + Origin) is the caller's job at wiring time — this package gathers no OS info and only carries the seam.
The pre-execution vs ambiguous taxonomy ¶
Classify splits errors into four RetryClasses grouped by side-effect provability: the PRE-EXECUTION group {ClassTimeWindow, ClassRateLimited} — the server provably rejected the request before any side effect, so a resend is safe even for a money mover — and the AMBIGUOUS group {ClassTransient} — the request may have executed (lost response), so only an idempotent request may be blindly resent. ClassFatal is everything else. This vocabulary is what the L2 order-place reconcile protocol consumes (IsPreExecution / IsAmbiguous).
Loop-safety invariants (every retry/reconnect loop in the tree) ¶
Auto-retry is spread across layers, so this is the single audit reference for "no loop can spin uncontrolled" — by a single class or any combination. Each loop below names its TERMINATION bound (what makes it stop / why it cannot run hot) and, where applicable, its structural BACKSTOP (a bound that holds even if the semantic logic is later broken). The governing rule across all of them: every iteration must make progress — it returns, sleeps (drawing down a finite budget), or spends one of a bounded number of no-sleep corrective retries; an iteration that does none is the only way to spin, and the backstops below catch exactly that.
retryEngine.run (retry.go) — the shared L0/L1 ladder behind ExecuteWithRetry and Client.Do. Bound: the sleeping classes (429, network/5xx) each draw down BudgetMs; EXCEED_TIME_WINDOW is a single no-sleep corrective resync capped by the `corrected` once-flag; everything else is single-shot or surfaced. Backstop: a hard iteration ceiling (MaxRetryIterations) — unreachable while the above hold, surfaces the last error if a future edit adds an unbounded path.
the order-place reconcile loop (placeOp.runReconcile, ops/op_place.go). It drives single-shot sends itself (the L1 ladder is NOT engaged: zero Policy), so a money mover is never blindly resent. Bound: 429/transient resends draw down RetryBudgetMs; EXCEED_TIME_WINDOW is one resync capped by `resynced`; DUPLICATE/fatal exit. Backstop: the same MaxRetryIterations ceiling; an exit without positive proof of placement fails safe to the UNKNOWN endgame (never "placed", never re-places).
the eventual-consistency read-back (lookupOrderTyped, ops/op_place.go). Bound: a fixed lookupAttempts cap with fixed spacing (~1s total). Each attempt's GET is itself bounded by the L1 ladder above.
ops.WalkHistory / the candles op (ops/history.go, ops/op_candles.go) — cursorless pagination. Bound: an explicit page cap (maxHistoryPages) / row ceiling (CandlesMaxLimit) plus no-progress guards; each page request is bounded by the L1 ladder.
clock.Syncer.Sync (internal/clock) — server-clock measurement. Not a loop: single-flight collapses concurrent triggers onto one probe and a cooldown reuses a fresh estimate, so a persistently rejected clock can neither loop nor storm /v2/time. This complements the per-call resync once-flags above: at most one resync per logical call, at most one probe per cooldown window process-wide.
stream.connManager.run (internal/stream) — the WebSocket reconnect loop, intentionally unbounded (a resilient stream reconnects forever). Its safety is a RATE bound, not a count: EVERY reconnect attempt is preceded by a jittered exponential backoff (capped at ReconnectMaxMs), applied once at the top of the loop so no path — refused dial, failed subscribe, or a connection accepted then instantly dropped — can re-dial without pausing. The lone no-backoff retry is a single EXCEED_TIME_WINDOW resync, capped by `resynced`. Definitive 4xx rejections and all-subscriptions-rejected are fatal; ctx cancellation always exits.
Dependency rule ¶
This package MUST NOT import internal/journal (the Recorder interface is the seam — concrete recorders live in a frontend) and MUST NOT import internal/clock (no cycle). The L1 client reads the shared clock through the Clock interface (clock.State satisfies it structurally) and resyncs through a plain Resync func() error hook, rather than holding a *clock.State. The clock measurement primitive (MeasureClockOffset) lives here; the shared estimate holder lives in internal/clock.
Index ¶
- Constants
- func AssertEd25519PrivatePEM(privatePEM string) error
- func Execute(r Request, opts Options) (json.RawMessage, error)
- func ExecuteWithRetry(req Request, opts Options, pol RetryPolicy) (json.RawMessage, error)
- func MaxRetryIterations(budgetMs int64) int
- func NextBackoffMs(b int64) int64
- func ParsePrivatePEM(pemStr string) (ed25519.PrivateKey, error)
- func ProbeIP(ctx context.Context, doer Doer, baseURL, userAgent string) (string, error)
- func PublicPEMFromPrivate(privatePEM string) (string, error)
- func PublicSPKIBase64URL(publicPEM string) (string, error)
- func RetryAfterMs(err error) int64
- func SchemeOf(s Signer) string
- func SignParams(key ed25519.PrivateKey, encodedParams string) string
- type Built
- type Call
- type CallInfo
- type Client
- type Clock
- type ClockOffset
- type Correction
- type Credentials
- type Decision
- type Doer
- type IPProber
- type KV
- type Keypair
- type Meta
- type Options
- type Origin
- type Outcome
- type Policy
- type Recorder
- type Request
- type RetryAction
- type RetryClass
- type RetryGovernor
- type RetryPolicy
- type Signer
Constants ¶
const ( SurfaceCLI = "cli" // single-shot endpoint commands SurfaceTUI = "tui" // interactive trading terminal SurfaceMonitor = "monitor" // streaming bot runtime SurfaceMCP = "mcp" // MCP server tools SurfaceDoctor = "doctor" // read-only diagnostics (never journaled) SurfaceStreamBackfill = "stream-backfill" // stream REST recovery reads (never journaled) SurfaceStreamWS = "stream-ws" // the WebSocket upgrade itself (User-Agent only; never journaled) )
The canonical call-surface identities. A surface names the frontend/context a Client acts for; it is stamped on Origin.Surface (and reused as the User-Agent component) and the journaling policy keys its per-surface rules on it (see callrec.DefaultPolicy). Defining them once here — rather than as raw string literals at every call site and in the policy switch — keeps the producer and the matcher from drifting on a typo.
const ( InitialBackoffMs = retryInitialBackoffMs MaxBackoffMs = retryMaxBackoffMs )
InitialBackoffMs and MaxBackoffMs are the exponential-backoff ladder bounds (100ms doubling to a 2000ms ceiling) for the ambiguous/rate-limited classes, exported so the L2 layer can run the same ladder. NextBackoffMs doubles a backoff, clamped at MaxBackoffMs.
Variables ¶
This section is empty.
Functions ¶
func AssertEd25519PrivatePEM ¶
AssertEd25519PrivatePEM validates a PEM without exposing the key.
func Execute ¶
func Execute(r Request, opts Options) (json.RawMessage, error)
Execute sends the request and unwraps the {success, data} envelope, returning the raw data bytes (field order preserved). A non-2xx or success:false response becomes an ApiError. It applies opts.TimeoutMs (default 15s) as a standalone deadline on a background context — see executeCtx for the variant that layers the per-attempt timeout onto a caller-supplied context.
func ExecuteWithRetry ¶
func ExecuteWithRetry(req Request, opts Options, pol RetryPolicy) (json.RawMessage, error)
ExecuteWithRetry runs Execute with the bounded, idempotency-gated retry policy described by pol. The retry taxonomy (only for Idempotent requests within BudgetMs):
- EXCEED_TIME_WINDOW — the server rejected the request at its clock gate BEFORE any side effect, so it is always safe to resend. If pol.Correct is set, ExecuteWithRetry measures the offset once, installs a corrected clock, and retries immediately (no backoff). This is the auto-correct path.
- HTTP 429 — honors error.retryAfterSec when present, else backs off.
- network/transport error and HTTP 5xx — exponential backoff. The cancel TRY_AGAIN "order is mid-processing, retry shortly" condition shares this backoff path (it is a definitive 4xx routed to ClassTransient for its mechanics; see Classify).
- any other 4xx / API rejection — surfaced immediately.
A non-idempotent request (the default) is sent exactly once.
func MaxRetryIterations ¶
MaxRetryIterations is the hard iteration ceiling for a budgeted retry loop — the structural backstop that guarantees such a loop cannot spin forever, independent of its per-class decision logic. Every *retrying* iteration sleeps at least retryInitialBackoffMs and the total sleep is bounded by budgetMs, so at most budgetMs/retryInitialBackoffMs iterations can sleep; retryFreeIterations covers the first attempt and the bounded no-sleep corrective retries. The ceiling is therefore UNREACHABLE while the budget and once-flag bounds hold — it exists so that if a future edit introduces a retry path that neither sleeps nor terminates, the loop fails safe (surfaces its last error) instead of looping. Deriving the term from the budget keeps it correct for any --retry-timeout: a larger budget legitimately permits more sleeping iterations, so the cap scales with it and never trips in normal use. Both budgeted retry loops in the tree — retryEngine.run here and the L2 order-place reconcile loop — bound themselves with this one function (see the "Loop-safety invariants" section of this package's doc.go).
func NextBackoffMs ¶
NextBackoffMs returns the next backoff step (doubling, clamped at MaxBackoffMs).
func ParsePrivatePEM ¶
func ParsePrivatePEM(pemStr string) (ed25519.PrivateKey, error)
ParsePrivatePEM decodes a PKCS#8 PEM and asserts it is an ED25519 key. The error messages never include key bytes.
func ProbeIP ¶
ProbeIP performs GET /v2/ip through doer and returns the trimmed plaintext IP the server echoes back. The family pin and source binding live in the doer the caller supplies (see netbind.FamilyDoer), so this carries no networking policy of its own. An empty userAgent falls back to "korbit-cli/<version>".
func PublicPEMFromPrivate ¶
PublicPEMFromPrivate derives the SPKI public-key PEM from a private-key PEM.
func PublicSPKIBase64URL ¶
PublicSPKIBase64URL returns the base64url (no padding) of the SPKI DER inside a PUBLIC KEY PEM — the transport form the developers portal's key-creation deep link expects. base64url is used deliberately: a standard-base64 '+' can be turned into a space by URL query parsing and corrupt the key, while base64url's alphabet ('-', '_', alphanumerics) is URL-safe and survives the round trip. The portal re-pads and re-wraps it into a PEM.
func RetryAfterMs ¶
RetryAfterMs returns the Retry-After value in ms if the error carried one (an HTTP 429 with the header), else 0. Exported for the L2 layer's own rate-limit handling.
func SchemeOf ¶
SchemeOf reports the signing scheme of a Signer ("ed25519", "hmac-sha256", or "unknown") for diagnostics. It reveals only the algorithm name, never any key material — safe to log.
func SignParams ¶
func SignParams(key ed25519.PrivateKey, encodedParams string) string
SignParams signs the exact encoded parameter string (with `signature` excluded) and returns the base64 signature to append last. Korbit verifies over the raw encoded bytes in sent order, so the caller must sign exactly what it sends and never re-encode afterwards.
Types ¶
type Built ¶
Built is a fully-formed HTTP request, ready to send or display (dry-run).
func Build ¶
Build forms the HTTP request, signing private calls the way Korbit verifies them: the ED25519 signature is computed over the exact url-encoded parameter string that is sent, with `signature` appended last. GET/DELETE carry params in the query string; POST carries them in a form-encoded body.
type Call ¶
Call is one logical API call, pre-signing. TimeoutMs overrides the client's per-attempt timeout for this call (0 = client default).
type CallInfo ¶
type CallInfo struct {
Origin Origin
Method string
Path string
BaseURL string
Auth bool
KeyName string // the named key signing this call ("" for public calls)
APIKeyID string // the public api-key id ("" for public calls)
// Safety is the read/write class when this call belongs to an operation; the
// journaling policy reads it to record writes and skip reads. Empty for an
// ad-hoc call, where Method classifies it instead.
Safety cmdmeta.Safety
// ParamsJSON is the pre-signing params as a JSON object (wire param names →
// values), or nil/empty when there are none. Pre-signing ONLY: never carries
// timestamp/recvWindow/signature.
ParamsJSON []byte
}
CallInfo describes one logical call for the journal seam, captured BEFORE signing. It deliberately carries only non-secret, pre-signing facts: ParamsJSON must be the request's pre-signing params — the timestamp, recvWindow, and signature are appended inside Build AFTER CallInfo is built, so they never reach a Recorder. The private key is never present.
type Client ¶
type Client struct {
// BaseURL is the API host (e.g. https://api.korbit.co.kr). Required.
BaseURL string
// Doer performs the HTTP request; nil uses http.DefaultClient.
Doer Doer
// Creds sign private calls; nil = public-only (a Call with Auth true then
// fails fast).
Creds *Credentials
// UserAgent overrides the User-Agent header; "" keeps the default
// ("korbit-cli/<version>"). Composing it is the caller's job (see Options.UserAgent).
UserAgent string
// Clock is the shared server-clock estimate this client signs against; nil =
// sign with the local wall clock. Wire it to one clock.State (via
// clock.Syncer.State) so a single estimate covers every surface.
Clock Clock
// Resync re-measures the server clock; it is the ONE resync primitive (wire
// it to clock.Syncer.Sync). On EXCEED_TIME_WINDOW (when the policy retries the
// pre-execution class) Do calls Resync once, then re-signs reading the now
// corrected Clock. nil = no auto-correct: EXCEED_TIME_WINDOW is then surfaced,
// not retried (the public and doctor surfaces leave it nil).
Resync func() error
// TimeoutMs is the per-attempt HTTP timeout; 0 = the package default (15s).
// A Call may override it per call.
TimeoutMs int
// Sleep is the inter-retry delay primitive; nil = time.Sleep. (Do also makes
// the wait context-aware regardless — see Do.)
Sleep func(time.Duration)
// Observe receives short retry-decision descriptions for --debug; nil = off.
Observe func(string)
// NewRecorder mints a FRESH per-call journal Recorder for one Do (reading the
// active operation from ctx and the call's params), so concurrent calls never
// share recorder state. nil — or a nil Recorder returned — means this call is
// not recorded.
NewRecorder func(ctx context.Context, call Call) Recorder
// Origin attributes calls to a frontend; flows into CallInfo.
Origin Origin
// KeyName / APIKeyID are recorded into CallInfo (the public key id; never a
// secret). APIKeyID is informational for the recorder — Creds.APIKeyID is
// what actually signs.
KeyName string
APIKeyID string
// Log is the optional operational logger for this client's wire activity
// (request target/timing/attempt at Debug, a network error about to be
// retried at Warn). nil = silent. It NEVER receives a secret, signature, or
// raw response body — only the method, path, host, status, and symbolic code.
Log *slog.Logger
}
Client is the single front door for calling Korbit: the L1 transport that signs REST calls and the WebSocket upgrade (SignHandshake), runs exactly the retry Policy it is HANDED (the zero Policy is a single shot), owns its clock (read via Clock, resync via Resync), and records each call through a per-call Recorder it mints itself. It holds NO call policy of its own — callers (L2 ops, frontends) decide the policy per call. See doc.go for the package-level contracts.
A Client is configured once and reused; Do is safe for concurrent use as long as the injected Clock/Resync/Sleep/Observe/NewRecorder are. NewRecorder mints a fresh per-call Recorder for every Do, so a shared Client has no shared mutable recording state.
func (*Client) Do ¶
Do executes one logical call under the given policy: it signs via the shared clock, runs exactly the retry ladder the policy selects, and (when a per-call Recorder is minted) gates the first send on Recorder.Ready and writes one Recorder.Record after the call completes.
On EXCEED_TIME_WINDOW, when Resync is wired and the policy retries the pre-execution class, Do resyncs the shared clock once and re-signs reading the now-corrected Clock (timestamp and widened recvWindow); with no Resync the rejection is surfaced, not retried.
Context: each attempt's HTTP deadline is the TIGHTER of Call.TimeoutMs (else the client default, 15s) and ctx. Inter-retry sleeps select on ctx and abort promptly on cancellation. A cancellation mid-retry surfaces ctx.Err() (context.Canceled or context.DeadlineExceeded); a cancellation mid-attempt surfaces the transport error from the aborted request (Execute's "...failed before a response was received" wrap), classified as transient.
One Do == one Recorder.Record. A Ready error aborts with nothing sent and is returned as Do's error (the call never started). A post-call journal failure is delivered through the Recorder's own sink, never via Do's error or Meta.
func (*Client) ServerNowMs ¶
ServerNowMs is the clock's best estimate of the server's current unix-ms time (un-leaned), for defaulting server-relative lookback windows sent on the wire. It reads the shared Clock (which honors a test clock seam), falling back to the local wall clock when no Clock is wired. L2 ops reads it for the history walk's default start.
func (*Client) SignHandshake ¶
SignHandshake builds the signed query string for the private WebSocket upgrade, the way the server verifies it: "timestamp[&recvWindow]&signature", with the signature computed over the exact encoded parameter string and appended last (the SAME ordered-encode + signature-last core REST signing uses, via signAuthParams). It signs with the shared Clock (and its widened recvWindow, omit-below-5s), so a correction discovered on any surface covers the next handshake too. The Signer's scheme is opaque here. It returns only the signed query — the caller owns dialing and the X-KAPI-KEY header (Client.APIKeyID). Errors only when the client is public-only (no Creds to sign with).
type Clock ¶
type Clock interface {
// SignNow is the corrected, lean-adjusted unix-ms timestamp to sign with.
SignNow() int64
// RecvWindowMs is the widened signed-request validity window in ms, 0 = omit
// (server default 5s). One value for every surface (omit-below-5s).
RecvWindowMs() int
// Offset is the raw server−local offset in ms (delivery-delay detection).
Offset() int64
// Measured reports whether a server-clock estimate has been installed yet
// (false = the bare local clock, offset 0). The stream layer's delivery-delay
// check reads it to decide whether a suspected delay against an unmeasured
// clock warrants a one-off reactive resync — otherwise a skewed local clock
// masquerades as delivery lag.
Measured() bool
// ServerNowMs is the clock's best estimate of the server's current unix-ms
// time (the local clock plus the raw Offset, UN-leaned — unlike SignNow,
// which leans into the past for the signing bound). It is the source for
// defaulting server-relative lookback windows sent on the wire (the history
// walk's start). It reads the same local-clock seam the estimate was measured
// against, so it honors a test clock.
ServerNowMs() int64
}
Clock is the read view of the shared server-clock estimate a Client signs against (and that the stream reads for delivery-delay detection). It is an interface so package korbit does not import internal/clock — clock.State satisfies it structurally, preserving the no-import-cycle invariant in both packages' doc.go. Resync (the measurement that updates the estimate) is NOT on this read-only view; it is Client.Resync, so a client can sign against a clock without being able to auto-correct it (the doctor surface signs with a raw, never-resynced clock to surface skew).
type ClockOffset ¶
ClockOffset is the result of measuring the local clock against Korbit's server clock. OffsetMs is the best estimate of (serverClock - localClock): add it to a local timestamp to get the server's notion of "now". RTTMinMs is the smallest round-trip seen across the probes; the estimate's uncertainty is about ±RTTMinMs/2 (the unmodelable path asymmetry), so callers that must not overshoot the server's tight future bound lean their corrected timestamp into the past by that much. Samples is how many probes succeeded.
func MeasureClockOffset ¶
MeasureClockOffset estimates the offset between the local clock (opts.Now) and the Korbit server clock by probing /v2/time `probes` times and keeping the sample with the smallest round-trip — NTP's minimum-delay filter, which rejects the queuing noise that inflates and skews the estimate. For each probe it brackets the request with two local readings and compares the server time to their midpoint, so symmetric latency cancels out:
offset_i = serverTime_i - (t0_i + t1_i)/2 rtt_i = t1_i - t0_i
The chosen sample is the one with min rtt. The call reuses opts.Doer, so over the default keep-alive client the probes ride one warm connection (no per-probe TLS handshake polluting the RTT). It makes an unauthenticated call, so Creds may be nil. Returns an error only if every probe failed. Each probe's HTTP timeout is min(opts.TimeoutMs, maxClockProbeTimeoutMs) — a slower probe's estimate is too uncertain to be worth waiting for, and the cap bounds the sequential-probe worst case (see maxClockProbeTimeoutMs).
log (nil = silent) receives Debug telemetry: one line per probe (RTT and whether it became the new min-delay pick) and one summary line with the chosen offset/RTTmin/uncertainty. It carries no secret — /v2/time is public.
func (ClockOffset) UncertaintyMs ¶
func (o ClockOffset) UncertaintyMs() int64
UncertaintyMs is the half-RTT bound on the offset estimate's error. It is the amount a caller should lean a corrected timestamp into the past so that even worst-case path asymmetry cannot push the signed timestamp past the server's fixed +1s future bound.
type Correction ¶
Correction is the clock adjustment to install on a corrective EXCEED_TIME_WINDOW retry. Now, when non-nil, REPLACES opts.Now with a corrected clock (the server-clock estimate, already leaned into the past by the measurement uncertainty) — it replaces rather than composes, so a request whose clock was already corrected proactively by --time-sync on is not double-adjusted. RecvWindowMs, when > 0, widens the signed-request validity window so the leaned timestamp still lands inside it on a slow/asymmetric link.
type Credentials ¶
Credentials sign a private request: the api-key id sent as X-KAPI-KEY and the Signer that produces the `signature` value (ED25519 or HMAC-SHA256).
type Decision ¶
type Decision struct {
Action RetryAction
Wait time.Duration
Reason string
}
Decision is what the ladder permits for one failed attempt. Wait is set only for RetryWait. Reason is a short human description for --debug Observe, and is "" when the caller supplies its own message (RetryResync) or the class is surfaced silently (a policy-gated or fatal give-up).
type IPProber ¶
type IPProber func(ctx context.Context, network, baseURL, userAgent string, timeoutMs int) (string, error)
IPProber fetches the caller's public IP from baseURL over a specific network family ("tcp4" or "tcp6") and returns the trimmed plaintext IP. Forcing the family is what lets the CLI report both the IPv4 and the IPv6 address Korbit sees you from, so an agent can allowlist whichever family it will connect over. It is a seam: the default dials the real network (the cli builds it over netbind.FamilyDoer); tests inject a stub. The userAgent is the caller-composed User-Agent; an empty string falls back to the bare "korbit-cli/<version>".
type Keypair ¶
Keypair is a generated ED25519 keypair in PEM form. The public key is SPKI; the private key is PKCS#8.
func GenerateKeypair ¶
GenerateKeypair creates a fresh ED25519 keypair.
type Meta ¶
Meta reports how a Do executed. A post-call journal-write failure is NOT here: the Recorder delivers it through its own sink (callrec's onPostFailure), never back through Do — so a journal fault can never be conflated with the API error.
type Options ¶
type Options struct {
BaseURL string
RecvWindow int // 0 = omit
TimeoutMs int
Creds *Credentials
Doer Doer
Now func() int64
// UserAgent overrides the User-Agent header. Empty string keeps the default
// ("korbit-cli/<version>"). Composing a richer string (program version, OS,
// Origin) is the CALLER's job at wiring time. This package gathers no OS
// info; it only carries the seam.
UserAgent string
}
Options configures request building and execution.
type Origin ¶
type Origin struct {
// Surface is the frontend identity, one of the Surface* constants below.
Surface string
// Detail is optional finer attribution (e.g. a sub-command), free-form.
Detail string
}
Origin attributes a logical call to the frontend that issued it: which Surface a Client acts for, with an optional finer Detail. It flows into CallInfo (the recorder seam); the journal `origin` column is reserved and not written.
type Outcome ¶
type Outcome struct {
// Err is the error Do is about to return (nil on success).
Err error
// HTTPStatus is the final HTTP status observed (0 if no response was
// received — a pre-response transport failure).
HTTPStatus int
// Code is the symbolic error code on an API rejection ("" otherwise).
Code string
// Message is a short human description of the failure ("" on success).
Message string
// Attempts is the number of underlying sends this logical call took.
Attempts int
// StartedAtMs / FinishedAtMs bracket the whole logical call (all attempts),
// in unix ms; DurationMs is their difference for convenience.
StartedAtMs int64
FinishedAtMs int64
DurationMs int64
}
Outcome is the post-call result handed to Recorder.Record. Exactly one of the success/error shapes is meaningful: on success Err is nil and HTTPStatus is the 2xx seen; on failure Err is the surfaced error and Code/HTTPStatus/Message describe it (from the ApiError when the failure was an API rejection).
type Policy ¶
type Policy struct {
// Idempotent gates the FULL retry ladder exactly as RetryPolicy.Idempotent:
// when true the ambiguous class (network/5xx) is retried with backoff, and
// the pre-execution classes too.
Idempotent bool
// RetryPreExec, when true, retries ONLY the provably-pre-execution classes —
// EXCEED_TIME_WINDOW (one corrective resync) and HTTP 429 (Retry-After /
// backoff within budget) — even when !Idempotent. The ambiguous classes
// (network/5xx) are NOT retried unless Idempotent. It exists for the L2
// reconcile path.
RetryPreExec bool
// BudgetMs bounds total inter-retry SLEEP (the corrective EXCEED_TIME_WINDOW
// resync does not draw it down). 0 disables the budgeted (sleeping) classes;
// the no-sleep corrective retry still runs when its gate allows.
BudgetMs int
}
Policy is the per-call retry policy — INPUT decided by the caller (L2 ops or a frontend), never by the Client. The zero value is a single shot (the fail-safe default, identical to the RetryPolicy zero value).
type Recorder ¶
type Recorder interface {
// Ready is the pre-send gate, called once BEFORE the first send of a call
// that will be recorded. An error aborts the call with nothing on the wire,
// preserving the open-journal-before-send hard guarantee (e.g. a read-only
// home fails at this point, having sent nothing). Returning nil permits the
// send.
Ready(info CallInfo) error
// Record is the post-call write, called exactly once after the logical call
// completes (success or failure) with the attempt count folded in — one
// logical Do == one record, one api_calls row per call. recordID is the
// stored row id (0 when not applicable). A write failure is NOT returned: the
// Recorder routes it through its own sink (see the severity contract above).
Record(info CallInfo, out Outcome) (recordID int64)
}
Recorder is the journal seam for the L1 client. It is an interface here on purpose: package korbit must NOT import internal/journal (that would invert the layering and pull SQLite into the wire layer). A frontend supplies the concrete Recorder.
Severity contract: the Recorder — not Client.Do — decides fail-vs-warn, and it delivers a POST-call failure entirely through its own sink (callrec's onPostFailure), never back through Do. So Record cannot return an error: a broken journal at write time is the Recorder's to surface (warn, toast, or capture-for-fatal), keeping the API error and the journal error on strictly separate channels. The only failure the client honors is a PRE-send Ready error, which aborts with nothing on the wire (the hard guarantee).
type RetryAction ¶
type RetryAction int
RetryAction is the mechanical decision Next returns.
const ( // RetryGiveUp: surface the error — not retryable under this policy, the // budget is exhausted, or no further clock correction is available. RetryGiveUp RetryAction = iota // RetryResync: perform the ONE clock resync (no sleep, does not draw down the // budget), then retry. Returned at most once per governor, only for // EXCEED_TIME_WINDOW when a resync hook is available. RetryResync // RetryWait: sleep Wait, then retry. RetryWait )
func (RetryAction) String ¶
func (a RetryAction) String() string
String renders the action as a short stable token, for an operational log attribute.
type RetryClass ¶
type RetryClass int
RetryClass is the four-way classification of an Execute error, exported so the L2 ops layer (order-place reconcile, history walking) can reason about whether a failed call can have had side effects.
The classes split into two groups by side-effect provability:
- the PRE-EXECUTION group — {ClassTimeWindow, ClassRateLimited} — the server provably rejected the request at a gate (its clock window, or rate limiter) BEFORE any side effect. Resending is always safe, even for a money mover. This is the group RetryPreExec retries.
- the AMBIGUOUS group — {ClassTransient} — a network/transport failure or an HTTP 5xx: the request MAY have executed (the response was lost). Only an idempotent request may be blindly resent; otherwise the caller must reconcile (the L2 reconcile protocol consumes exactly this distinction). The TRY_AGAIN cancel "retry shortly" condition is also routed to ClassTransient — not because it is ambiguous (it is side-effect-free) but because it wants the same idempotent-only backoff retry.
- ClassFatal — any other API rejection (4xx other than 429, success:false): resending cannot help, so it is surfaced immediately.
Use IsPreExecution / IsAmbiguous to query the grouping by name.
const ( // ClassFatal is a non-retryable failure: surface it. ClassFatal RetryClass = iota // ClassTimeWindow is EXCEED_TIME_WINDOW — pre-execution; resync the clock // and retry. ClassTimeWindow // ClassRateLimited is HTTP 429 — pre-execution; honor Retry-After else back // off. ClassRateLimited // ClassTransient is retried with exponential backoff, and only for an // idempotent request. Its members: a network/transport error or HTTP 5xx — // AMBIGUOUS (may have executed), which is exactly why the idempotency gate // matters — and the TRY_AGAIN "order is being processed, retry shortly" // cancel condition, which is side-effect-free and cancel-only (so it always // satisfies the gate) but shares the same backoff-retry mechanics. ClassTransient )
func Classify ¶
func Classify(err error) RetryClass
Classify maps an Execute error to its RetryClass. A non-ApiError is a transport/network failure (Execute wraps those), which is ambiguous; ApiErrors are classified by symbolic code and HTTP status.
func (RetryClass) IsAmbiguous ¶
func (c RetryClass) IsAmbiguous() bool
IsAmbiguous reports whether the class is the ambiguous group: the request may have executed, so only an idempotent request may be blindly resent.
func (RetryClass) IsPreExecution ¶
func (c RetryClass) IsPreExecution() bool
IsPreExecution reports whether the class is in the pre-execution group: the server provably rejected the request before any side effect, so resending is safe even for a non-idempotent (money-moving) request.
func (RetryClass) String ¶
func (c RetryClass) String() string
String renders the class as a short stable token, for an operational log attribute or the Observe seam.
type RetryGovernor ¶
type RetryGovernor struct {
// contains filtered or unexported fields
}
RetryGovernor is the ONE retry ladder shared by the budgeted retry loops in the tree — the L1 retryEngine (here) and the L2 order-place reconcile loop (placeOp.runReconcile). It encapsulates the whole ladder policy: the four-class taxonomy's mechanics, the exponential backoff schedule, the HTTP 429 Retry-After honor, the single no-sleep EXCEED_TIME_WINDOW corrective retry, the total sleep budget, and the hard iteration ceiling (the structural backstop). It performs NO I/O — it never sleeps, resyncs, or sends. The caller drives its own loop and, per failed attempt, asks Next what the ladder permits, then carries out the returned action with its own sleep/resync mechanism. This keeps the policy in one place while each caller keeps its own control-flow shell: the engine drives a send func and surfaces errors directly, whereas the reconcile loop interleaves DUPLICATE-resolution and the eventual-consistency endgame, mapping each give-up class to its own clean-fail vs UNKNOWN semantics.
The two policy axes select which classes are retried:
- idempotent: when true, the ambiguous class (network/5xx) is retried with backoff (the idempotency gate).
- retryPre: when true, the pre-execution classes (EXCEED_TIME_WINDOW via one corrective resync, HTTP 429 via Retry-After/backoff) are retried even when !idempotent.
When neither axis applies to a class that class is surfaced (RetryGiveUp); when neither applies at all the loop is effectively a single shot.
The reconnect loop (stream.connManager.run) deliberately does NOT use a governor: it is unbounded by design (a resilient stream reconnects forever), has no sleep budget, and classifies handshake rejections rather than Classify errors, so its safety is a pure rate bound (always back off) — a structurally different shape. See the "Loop-safety invariants" section of doc.go.
func NewRetryGovernor ¶
func NewRetryGovernor(idempotent, retryPre, canResync bool, budgetMs int64) *RetryGovernor
NewRetryGovernor builds a governor for one logical call. canResync reports whether the caller has a clock-resync hook (when false, EXCEED_TIME_WINDOW is surfaced rather than corrected). budgetMs bounds total inter-retry sleep; it also sets the hard iteration ceiling via MaxRetryIterations.
func (*RetryGovernor) Attempt ¶
func (g *RetryGovernor) Attempt() bool
Attempt reports whether another attempt may run, enforcing the hard iteration ceiling. Drive the loop with it as the condition: `for g.Attempt() { ... }`. It returns false once the ceiling is reached — unreachable while the budget and resync-once bounds hold, so the caller must treat a false here as the fail-safe backstop (surface the last error / route to its unknown endgame).
func (*RetryGovernor) Attempts ¶
func (g *RetryGovernor) Attempts() int
Attempts is the number of attempts begun so far (equals the caller's send count; handy for a journal / Result.Attempts).
func (*RetryGovernor) Next ¶
func (g *RetryGovernor) Next(class RetryClass, retryAfterMs int64) Decision
Next decides what the ladder permits for a failed attempt of the given class. retryAfterMs is the request's Retry-After in ms (0 if none), honored only for ClassRateLimited. It updates the governor's budget / backoff / resync-once state as a side effect of the decision it returns, and performs no I/O.
type RetryPolicy ¶
type RetryPolicy struct {
// Idempotent is the master gate. When false, ExecuteWithRetry sends exactly
// once and surfaces the first error, no matter its kind — so money-moving
// writes and order placement are never auto-resent on an ambiguous failure.
// Only GET endpoints and explicitly-idempotent writes (cancel, deposit
// address generate) set this true.
Idempotent bool
// BudgetMs bounds the total time the layer will spend SLEEPING between
// retries (the sum of backoff/Retry-After waits). Each attempt additionally
// keeps its own opts.TimeoutMs; the corrective EXCEED_TIME_WINDOW retry does
// not sleep and so does not draw down the budget. 0 disables retry entirely
// (single shot) even when Idempotent. Tracking scheduled sleep rather than a
// wall clock keeps the layer deterministic and testable without a fake clock.
BudgetMs int
// Correct re-measures the clock for the one corrective retry after an
// EXCEED_TIME_WINDOW rejection. nil means no correction is available, in
// which case EXCEED_TIME_WINDOW is treated as a normal (non-corrective)
// failure and not retried. Called at most once per ExecuteWithRetry.
Correct func() (Correction, error)
// Sleep is the delay between retries; injectable for tests. Defaults to
// time.Sleep.
Sleep func(time.Duration)
// Attempts, when non-nil, is incremented once per underlying Execute call
// (including the single-shot path and the first attempt), so the caller can
// record how many sends a request actually took. retries = *Attempts - 1.
Attempts *int
// Observe, when non-nil, is called with a short human description each time a
// failed attempt leads to a retry decision (a retry, or giving up because the
// budget is exhausted) — for --debug diagnostics. Not called on success or a
// non-retryable (fatal) failure, which the caller surfaces directly.
Observe func(string)
}
RetryPolicy configures ExecuteWithRetry. The zero value (Idempotent false / BudgetMs 0) is a single shot — identical to calling Execute directly — which is the deliberate fail-safe default for any request that must not be resent.
type Signer ¶
Signer produces the value of the `signature` parameter for an encoded parameter string, the way the server verifies it. It is the abstraction over the supported key types: ED25519 returns a base64 detached signature, HMAC-SHA256 a lowercase-hex MAC. Both sign EXACTLY the bytes the caller sends, so a Signer must never re-encode its input. A Signer carries secret material, so it redacts itself under every fmt verb — it rides inside Credentials, which diagnostics may render.
func NewEd25519Signer ¶
func NewEd25519Signer(key ed25519.PrivateKey) Signer
NewEd25519Signer returns a Signer that produces a base64 ED25519 signature.
func NewHMACSHA256Signer ¶
NewHMACSHA256Signer returns a Signer that produces a lowercase-hex HMAC-SHA256 MAC. The secret's raw bytes are the HMAC key (Korbit issues the secret as a text string; its UTF-8 bytes are used verbatim).