Client
Pronounced “client”. Apparently that one needed saying too.
klhayyent is the outbound half of aichteeteapee. It handles outbound HTTP without making every service rebuild the same wrapper, and it speaks the root package’s header names, content types, request ID and ErrorResponse envelope. One Client is safe to use from many goroutines.
Basic use
client, err := klhayyent.New("https://api.example.com/v1")
if err != nil {
return err
}
var output Widget
response, err := client.Get(
ctx,
"/widgets/42",
&output,
klhayyent.WithBearerToken(token),
klhayyent.WithQuery("view", "summary"),
)
if err != nil {
return err
}
Get, Options and Delete take a response target; Post, Put and Patch take a request body and a response target; Head takes neither. They all call Do(ctx, method, target, requestBody, responseBody, options...). Every 2xx status is a success, and an empty success body needs no target.
Targets are joined onto the base URL’s path, so /widgets/42 on https://api.example.com/v1 requests https://api.example.com/v1/widgets/42. The returned *Response holds StatusCode, Header and the buffered Body.
Bodies
The request body is encoded by its Go type, and Content-Type is set unless you set it yourself:
| Request body | Sent as | Content-Type |
|---|---|---|
nil | no body | none |
io.Reader | streamed as is | none, set it with WithHeader |
[]byte | raw bytes | application/octet-stream |
string | text | text/plain |
url.Values | form encoded | application/x-www-form-urlencoded |
| anything else | json.Marshal | application/json |
The response decodes as JSON into the target by default. A *[]byte target gets the raw body, and an io.Writer target has the raw body written to it.
Every request sends Accept: application/json unless you change the default headers. When the context carries a request ID from the RequestID middleware, the client sends it as X-Request-ID, so one ID follows a request across services.
Errors and limits
Response bodies are limited to 10 MiB by default (DefaultMaxResponseBodySize). Change the limit with WithMaxResponseBodySize. A bigger body fails with an error that matches ErrResponseBodyTooLarge through errors.Is.
A non-2xx response returns both the *Response and an *HTTPError. The error holds the decoded aichteeteapee.ErrorResponse. When the body is not that envelope, the client fills one in from the status: the code from ErrorCodeFromHTTPStatus and the standard status text as the message.
var httpError *klhayyent.HTTPError
if errors.As(err, &httpError) {
log.Error("dependency rejected request", "code", httpError.ErrorResponse.Code)
}
if errors.Is(err, aichteeteapee.ErrNotFound) {
// 404
}
HTTPError unwraps to the root package sentinel for its status:
| Status | Sentinel |
|---|---|
| 400 | ErrBadRequest |
| 401 | ErrUnauthorized |
| 403 | ErrForbidden |
| 404 | ErrNotFound |
| 405 | ErrMethodNotAllowed |
| 409 | ErrConflict |
| 410 | ErrGone |
| 422 | ErrUnprocessableEntity |
| 429 | ErrTooManyRequests |
| 500 | ErrInternalServer |
| 502 | ErrBadGateway |
| 503 | ErrServiceUnavailable |
| 504 | ErrGatewayTimeout |
| anything else | ErrUnexpectedResponseStatus |
Timeouts and context cancellation keep their error chain, so errors.Is(err, context.DeadlineExceeded) works. The *url.Error layer is stripped first, so the URL never ends up in an error message or a log. Construction and option mistakes match ErrInvalidBaseURL or ErrInvalidOption, and a rejected target matches ErrInvalidRequestTarget.
Cookies
Cookie handling is on by default. Each client gets an RFC 6265 cookie jar backed by the public suffix list, so cookies set by one response are scoped and sent on later requests. The standard library jar handles Secure, domain, path, expiry and HttpOnly.
Use WithCookieJar to share or replace the jar, and client.CookieJar() to read it. WithHTTPClient keeps a jar already installed on the supplied client unless WithCookieJar overrides it. Construction shallow-copies the supplied http.Client, so timeout, redirect and jar changes made by klhayyent do not mutate the caller’s value.
Security boundary
The base URL is trusted configuration. It must be an absolute HTTP or HTTPS URL without user information, a query or a fragment. Request targets must be relative. Absolute targets, scheme-relative targets, control characters, backslashes, fragments and encoded traversal segments are rejected before the transport runs.
Redirects stay on the base URL’s scheme and host and stop after ten hops. WithCheckRedirect can add a stricter policy but cannot bypass the same-origin check. That blocks redirect-based credential and cookie disclosure. The client never logs URLs, queries, headers, cookies or bodies; a request log line has the dependency name, method, status, duration and outcome.
For services that must accept an untrusted destination, validate it before constructing a client and apply the service’s SSRF policy, including private address and DNS rebinding controls. klhayyent intentionally treats the base URL as trusted dependency configuration.
Client options
| Option | Default | Effect |
|---|---|---|
WithHTTPClient | a new http.Client | Use a shallow copy of an existing client. |
WithTransport | http.DefaultTransport | Use this http.RoundTripper. |
WithTimeout | 30s | Total request timeout. Zero turns it off. |
WithCookieJar | public suffix jar | Use this cookie jar. |
WithDefaultHeader | Accept: application/json | Set one header on every request. |
WithDefaultHeaders | Replace all default headers. | |
WithMaxResponseBodySize | 10 MiB | Largest buffered response body. Must be positive. |
WithDependencyName | http_client | Name in the request log lines. Keep it a short fixed label. |
WithCheckRedirect | none | Extra redirect policy, run after the same-origin check. |
A nil, empty or negative value where one is required makes New fail with ErrInvalidOption.
Request options
WithHeader, WithHeaders, WithBearerToken, WithBasicAuth, WithQuery and WithQueryValues apply to one call. They work on per-call copies, so they are safe with concurrent requests. Header names and values are validated, and query values are merged with any query already on the target.