Documentation
¶
Overview ¶
Package hook0 is the Go SDK for Hook0, an open source Webhooks-as-a-Service platform.
Two halves live here. This one is hand-written: sending an event, upserting the event types an application uses, and verifying that a webhook came from Hook0 unchanged. The other is generated from the OpenAPI snapshot the API commits — one type per schema it declares, one error value per problem it can report, one method per operation — and is reached through the generated package beside this one, over the transport this one exports.
Sending an event is idempotent, and retried ¶
SendEvent sends every event under an identifier this client knows: the one set on the Event, or a UUIDv7 it generates when the event carries none. Passing none does not mean the identifier comes from Hook0 — the value comes from here, travels with the request, and is what SendEvent answers.
That is what makes retrying safe. Hook0 keys events on that identifier, so a request repeated after a network failure or a server error ingests the event once rather than twice; without a client-chosen identifier, a repeated request would create a second event and deliver it to every subscriber. It also gives the answer to a retry its meaning: EventAlreadyIngested in reply to a repeated request says an earlier attempt of that same send reached the API, so the send succeeded. The same answer to a first attempt is a genuine conflict and is reported as one.
Only what could end differently is retried: a request that got no answer, a server error, and an instance saying it is being reached faster than it accepts. What the API refuses outright — a quota that is spent, a payload it will not read — is answered as is, since repeating it would only spend the same round trip again. The verdict for every problem the API can report is written down in the conformance corpus committed beside this module, which the suite here reads.
A send is bounded on five axes, each of them the caller's to set: the size of the payload, which is refused before a socket is opened; how long one attempt is given; how many attempts are made; how long a single wait between them may be; and how long every wait of one send may add up to.
Index ¶
- Constants
- Variables
- func GenerateEventId() string
- func VerifyWebhookSignature(signature string, payload []byte, headers http.Header, ...) error
- func VerifyWebhookSignatureAt(signature string, payload []byte, headers http.Header, ...) error
- type Client
- func (c *Client) APIURL() string
- func (c *Client) ApplicationId() string
- func (c *Client) Options() Options
- func (c *Client) SendEvent(ctx context.Context, event Event) (string, error)
- func (c *Client) Transport() *Transport
- func (c *Client) UpsertEventTypes(ctx context.Context, eventTypes []string) ([]string, error)
- type Event
- type EventType
- type EventTypeError
- type Options
- type RetryPolicy
- type SendError
- type Signature
- type Transport
- type TransportError
Constants ¶
const ( // DefaultMaxPayloadBytes is the largest event payload the client agrees to send. // // Hook0's API refuses request bodies above 2 MiB, so a payload above 1 MiB is already at risk of // being refused once the JSON envelope around it — metadata, labels, identifiers — is counted. // The client rules such an event out rather than spending a round trip, and every retry after // it, on a request that cannot be accepted. DefaultMaxPayloadBytes = 1024 * 1024 // MaxAttemptsCap is the most attempts a retry policy can ever make, whatever MaxAttempts says. // // A policy is configuration, and configuration can be wrong; this cap keeps a mistyped // MaxAttempts from turning one send into an unbounded series of requests. MaxAttemptsCap = 16 // AlreadyIngested is the identifier Hook0 gives the problem it answers when an event identifier // is already taken. AlreadyIngested = "EventAlreadyIngested" // RateLimited is the identifier Hook0 gives the problem it answers when requests are reaching // the instance faster than it accepts them. // // It shares its status with the quota problems, and is the only one of them worth repeating: a // quota clears when a plan changes or a day turns, neither of which happens inside the seconds a // send is given, while pacing clears on its own and the answer says when. RateLimited = "RateLimited" )
const ( // DefaultRequestTimeout is the longest one attempt at reaching the API is given before it is // abandoned. // // Ten seconds is far above what ingesting an event takes when the API is healthy, and short // enough that a stuck connection does not hold a caller for a noticeable time. DefaultRequestTimeout = 10 * time.Second // DefaultMaxResponseBytes is the largest response body read off a socket. DefaultMaxResponseBytes int64 = 8 * 1024 * 1024 // MaxResponseHeaders is the most headers read out of one answer, and MaxHeaderBytes the longest // one of them may be. // // The head of an answer is written by the other end, so it is bounded like the body: a server // that is broken or hostile can otherwise spend a caller's memory on headers alone. Both are // the numbers the conformance corpus names, so that no two SDKs bound different things. MaxResponseHeaders = 64 MaxHeaderBytes = 64 * 1024 // MaxHeadBytes is the largest whole head an answer may carry, every line counted together. // // This is the one that bounds what a head costs, because it bounds the total: a line count and // a size per line multiply, and the two above admit sixty-four lines of sixty-four kilobytes // between them. They earn their place by refusing early, on the line that crosses them rather // than at the end of the head; this one sets the ceiling. // // Sixteen kilobytes is the ceiling of the strictest runtime any target runs on, which is what // makes it a number every target can apply in library code. It is applied here rather than left // to MaxResponseHeaderBytes below: that one is an outer wall, set far above this so that what // refuses an abusive head is this client's own ceiling rather than whatever the runtime of the // day happens to allow. MaxHeadBytes = 16 * 1024 )
Variables ¶
var ( // ErrPayloadTooLarge is an event whose payload is larger than the client agrees to send. It is // answered before a socket is opened, so nothing was sent when a caller sees it. ErrPayloadTooLarge = errors.New("the event payload is larger than this client sends") // ErrInvalidEventType is an event type that does not read as `service.resource_type.verb`. ErrInvalidEventType = errors.New("the event type does not have a valid syntax") // ErrUnreachable is a request that got no answer: a connection refused or reset, an attempt out // of time, a body that stopped mid-way. // // It is the one failure of a send that could end differently, which is why it is told apart // from the others rather than grouped with them under the type that carries them all. None of // these says whether the API acted on the request, which is exactly why a send carries an // identifier the client chose itself. ErrUnreachable = errors.New("the API could not be reached") // ErrAnswerAboveABound is an answer that crossed a ceiling this client set for itself: a body, // a header, or a number of headers above what it agrees to read. // // Repeating the request draws the same answer, so it is reported rather than retried: a client // that retries it reads the oversized answer four times and then blames the network. ErrAnswerAboveABound = errors.New("the API answered more than this client reads") // ErrUnusableAPIURL is an API URL no request can be sent to. Nothing was sent when a caller // sees it, and building the same request again would fail the same way. ErrUnusableAPIURL = errors.New("the API URL is not one a request can be sent to") // ErrSignatureUnreadable is a signature header this client cannot read whole: a part it needs // that is missing, a moment that is not a number of seconds, a code that is not hexadecimal. ErrSignatureUnreadable = errors.New("the signature header cannot be read") // ErrHeaderNotDelivered is a header the signature says it covers that the request did not // carry. Signing over an absent value would let a sender drop a header and keep the signature // valid, so this is refused before any code is computed. ErrHeaderNotDelivered = errors.New("a header the signature covers was not delivered") // ErrSignatureMismatch is a code that is not the one the subscription secret produces. ErrSignatureMismatch = errors.New("the signature does not match what the subscription secret produces") // ErrSignatureOutsideTolerance is a moment sitting further from now than the caller accepts, in // either direction: a delivery captured and replayed later, and one dated in the future by a // clock that is ahead or by a sender widening its own acceptance window, are refused alike. ErrSignatureOutsideTolerance = errors.New("the signature's moment sits outside the tolerance accepted") )
The reasons this client refuses to do what it was asked, as values errors.Is compares against.
A caller that only wants to know whether to try again reads the sentinel; a caller that wants the numbers reads the error the sentinel is wrapped in.
Functions ¶
func GenerateEventId ¶
func GenerateEventId() string
GenerateEventId answers a UUIDv7, the shape of identifier Hook0 mints when it is the one choosing.
Its leading 48 bits are the current time in milliseconds, so identifiers generated in sequence are ordered, which is what keeps the index they end up in from being written all over.
func VerifyWebhookSignature ¶
func VerifyWebhookSignature( signature string, payload []byte, headers http.Header, subscriptionSecret string, tolerance time.Duration, ) error
VerifyWebhookSignature verifies a webhook against the current moment.
See VerifyWebhookSignatureAt for what each argument is.
func VerifyWebhookSignatureAt ¶
func VerifyWebhookSignatureAt( signature string, payload []byte, headers http.Header, subscriptionSecret string, tolerance time.Duration, currentTime time.Time, ) error
VerifyWebhookSignatureAt verifies a webhook against a moment the caller names.
- signature: the value of the `X-Hook0-Signature` header.
- payload: the raw body of the webhook request.
- headers: the headers of the webhook request.
- subscriptionSecret: the signing secret of the subscription the webhook was delivered for.
- tolerance: how far, in either direction, the moment the signature names may sit from currentTime. Five minutes is a reasonable trade-off between tolerating clock drift and bounding how long a captured delivery can be replayed.
- currentTime: what to hold the signature's moment against.
Every reason a webhook is refused is one of the sentinels this package declares, so errors.Is tells a missing header from a code that does not match from a moment out of the window.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the Hook0 client, built once and shared wherever an application sends events.
func NewClient ¶
NewClient builds a client reaching an instance of the API.
- apiURL: base API URL of a Hook0 instance, such as https://app.hook0.com/api/v1.
- applicationId: identifier of the Hook0 application events are sent to.
- token: an authentication token valid for that application.
- options: the bounds one send is held to.
func (*Client) ApplicationId ¶
ApplicationId is the application this client sends events to.
type Event ¶
type Event struct {
// EventType is the type of the event, as the application declares it.
EventType string
// Payload is what the event carries.
Payload string
// PayloadContentType says how to read the payload.
PayloadContentType string
// Labels are what Hook0 routes the event by.
Labels map[string]string
// Metadata is anything else worth carrying, nil when there is none.
Metadata map[string]string
// OccurredAt is when the event happened; the zero moment means now.
OccurredAt time.Time
// EventId is what to key the event on, empty when the client is to choose.
EventId string
}
Event is an event to send to Hook0.
EventId is the caller's to set when it already has one to key the event on. Left empty, the client generates a UUIDv7, sends it and answers it — which is what lets it repeat a request without risking a second copy of the event being ingested and delivered to every subscriber.
type EventType ¶
type EventType struct {
// Service is the leading segment.
Service string
// ResourceType is the middle segment.
ResourceType string
// Verb is the trailing segment.
Verb string
}
EventType is an event type, read out of the `service.resource_type.verb` it is written as.
func ParseEventType ¶
ParseEventType reads an event type, refusing one that does not name all three of its parts.
type EventTypeError ¶
type EventTypeError struct {
// EventType is the one that was asked for.
EventType string
// Detail is what went wrong, in the words a caller is given.
Detail string
// Err is the reason underneath, nil when there is none to name.
Err error
}
EventTypeError is an event type this client would not use or could not create.
func (*EventTypeError) Error ¶
func (e *EventTypeError) Error() string
Error says which event type failed, and why.
func (*EventTypeError) Unwrap ¶
func (e *EventTypeError) Unwrap() error
Unwrap answers the reason underneath, which is what lets errors.Is name it.
type Options ¶
type Options struct {
// RetryPolicy is how the attempts of one send are spaced out.
RetryPolicy RetryPolicy
// RequestTimeout is how long one attempt is given.
RequestTimeout time.Duration
// MaxPayloadBytes is the largest payload sent, refused before a socket is opened.
MaxPayloadBytes int
// MaxResponseBytes is the largest answer read off a socket.
MaxResponseBytes int64
}
Options is every bound a client applies to one send.
func DefaultOptions ¶
func DefaultOptions() Options
DefaultOptions is the bounds a client applies when the caller names none.
type RetryPolicy ¶
type RetryPolicy struct {
// MaxAttempts is how many attempts a single send makes at most, the first one included. One
// disables retrying, and nothing above MaxAttemptsCap is honoured.
MaxAttempts int
// InitialBackoff is the ceiling of the delay before the first retry.
InitialBackoff time.Duration
// MaxBackoff is the ceiling no single delay ever exceeds, however many retries were made.
MaxBackoff time.Duration
// MaxTotalDelay is the budget all the delays of one send share.
MaxTotalDelay time.Duration
}
RetryPolicy says how a client spaces out the attempts of a single send.
The delay before a retry doubles from InitialBackoff and is capped by MaxBackoff; the delay actually waited is then drawn anywhere between zero and that ceiling, so that emitters which failed at the same moment do not come back at the same moment. Retrying stops as soon as the delays of the send would add up to more than MaxTotalDelay.
func DefaultRetryPolicy ¶
func DefaultRetryPolicy() RetryPolicy
DefaultRetryPolicy is four attempts spread over at most five seconds.
Three retries absorb the blips a webhook emitter meets in production — a connection reset, a rolling deployment answering 503 — without holding the caller for long, and the five-second budget bounds what the worst send costs whatever the individual delays turn out to be.
func DisabledRetryPolicy ¶
func DisabledRetryPolicy() RetryPolicy
DisabledRetryPolicy never retries: one attempt, and the caller hears what it answered.
func (RetryPolicy) Attempts ¶
func (p RetryPolicy) Attempts() int
Attempts is how many attempts this policy actually makes: MaxAttempts, brought back inside one and MaxAttemptsCap.
func (RetryPolicy) BackoffCeiling ¶
func (p RetryPolicy) BackoffCeiling(retry int) time.Duration
BackoffCeiling is the ceiling of the delay before retry number retry, where one is the first retry.
It doubles from InitialBackoff and never exceeds MaxBackoff, so the ceilings of successive retries never decrease.
func (RetryPolicy) Delays ¶
func (p RetryPolicy) Delays(draws []float64) []time.Duration
Delays is what this policy waits between the attempts of one send, one per retry, given one draw in [0, 1) per retry.
Each delay lands between zero and the ceiling of its retry, and the schedule is cut short as soon as the next delay would spend more than MaxTotalDelay. There are therefore at most Attempts() - 1 delays, and they add up to at most MaxTotalDelay.
A draw that is missing or is not a finite number is read as one, which asks for the whole ceiling: an unusable source of randomness makes the client wait longer, never less.
type SendError ¶
type SendError struct {
// EventId is the identifier the request carried, whether the caller chose it or this client
// generated it.
EventId string
// Attempts is how many requests were issued, the first one included. Zero when the send was
// refused before any socket was opened.
Attempts int
// Waited is how much of the delay budget the retries spent.
Waited time.Duration
// Detail is what the last attempt ran into, in the words a caller is given.
Detail string
// Err is the reason underneath, nil when the failure is only what the API answered.
Err error
}
SendError is what a send that did not ingest an event answers with.
It says how many attempts were made and how long they spent waiting, which is the difference between a transient outage this client rode out and a request the API will never accept. What went wrong underneath is under Unwrap, so errors.Is finds it.
type Signature ¶
type Signature struct {
// Timestamp is the moment the delivery was signed, in whole seconds since the epoch.
Timestamp int64
// CoveredHeaders names the headers the stronger scheme covers, in the order it covers them and
// lowercased.
CoveredHeaders []string
// BodyCode is the `v0` code, nil when the signature offers none.
BodyCode []byte
// HeadersCode is the `v1` code, nil when the signature offers none.
HeadersCode []byte
}
Signature is a signature header, read into the pieces a verification needs.
func ParseSignature ¶
ParseSignature reads a signature header, refusing anything it cannot read whole.
func (*Signature) Verify ¶
Verify reports whether the code this signature carries is the one the secret produces.
The stronger scheme wins when both are offered, and the comparison is made in constant time: one that gave up at the first differing byte would say, by how long it took, how much of a guess was right.
type Transport ¶
type Transport struct {
// contains filtered or unexported fields
}
Transport issues one request and reads the answer.
It answers the shape the generated package declares, so a generated operation group is built on one of these directly.
func NewTransport ¶
func NewTransport(baseURL string, token string, timeout time.Duration, maxResponseBytes int64) *Transport
NewTransport builds a transport reaching an instance of the API with a token valid for it.
A timeout or a ceiling that names nothing is the default rather than no bound at all: a transport with no timeout is one a single hung connection holds forever.
func (*Transport) Deliver ¶
func (t *Transport) Deliver( ctx context.Context, method string, path string, query url.Values, body any, ) (int, http.Header, []byte, error)
Deliver issues one request and answers the status, the headers, and the body.
It is Request with what the answer carried beside its body, which is what a client reads when the API names how long to wait before the request becomes servable again.
func (*Transport) Request ¶
func (t *Transport) Request( ctx context.Context, method string, path string, query url.Values, body any, ) (int, []byte, error)
Request issues one request and answers the status, the body, and why it got neither.
A refusal is an answer: the status and the body are what say whether repeating the request could end differently, so they are answered rather than raised over. Only a request that got no answer at all is an error here.
This is the shape the generated package declares, which reads what the API sent and nothing about how it was sent. A caller that also needs the headers — the delay a paced instance names beside a refusal is one — asks Deliver for them.
type TransportError ¶
type TransportError struct {
// Detail says what went wrong, in the words a caller is given.
Detail string
// Err is the nature of the failure, and under it whatever the standard library reported.
Err error
}
TransportError is a request that produced no answer to read.
Several natures of failure land here — a connection that was refused or reset, an answer above a ceiling this client set for itself, a URL nothing can be sent to — and only the first of them could end differently. What a send retries is therefore decided by errors.Is against ErrUnreachable, ErrAnswerAboveABound and ErrUnusableAPIURL, never by this type: a client deciding by the type spends four attempts on a mistyped API URL and then hands its caller a message that accuses the network.
func (*TransportError) Error ¶
func (e *TransportError) Error() string
Error says why the API was not reached.
func (*TransportError) Unwrap ¶
func (e *TransportError) Unwrap() error
Unwrap answers what the standard library reported.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package generated carries everything the API document describes: one type per schema it declares, one closed list of constants per enumeration it names, one error value per problem it can report, and one method per operation, grouped by the entity its operation id names.
|
Package generated carries everything the API document describes: one type per schema it declares, one closed list of constants per enumeration it names, one error value per problem it can report, and one method per operation, grouped by the entity its operation id names. |