Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ _Avoid_: Synchronous handler, fire-and-forget goroutine
A runtime-derived context that outlives the inbound request so a deferred handler keeps working after acknowledgement; bounded by a detach timeout and cancelled by **Runtime Shutdown**.
_Avoid_: Request context, context.Background()

**Admission Bound**:
The per-instance cap on admitted-but-incomplete deferred deliveries (`MaxDetached`, optionally `MaxDetachedPerTenant` per installation): a delivery arriving at the cap is rejected before acknowledgement and before dedupe marking with a typed signal the adapter maps to a shape-aware overload response.
_Avoid_: Rate limit, drop policy, queue depth

**Accepted Event**:
A verified and normalized inbound event that the runtime has taken responsibility for, regardless of handler success.
_Avoid_: Successful handler, retriable event
Expand Down
88 changes: 88 additions & 0 deletions adapters/linear/admission_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package linear_test

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

"github.com/coder/chat"
"github.com/coder/chat/adapters/linear"
"github.com/coder/chat/state/memory"
)

// TestAdmissionRejectionAnswersRetryStatus proves the Linear mapping end to
// end: Linear redelivers webhooks on non-2xx, so a delivery the runtime's
// Admission Bound rejects (ADR 0015) is answered with a retry-inducing 503 —
// never acknowledged as handled.
func TestAdmissionRejectionAnswersRetryStatus(t *testing.T) {
t.Parallel()

api := newLinearAPIServer(t, 3600)
now := time.UnixMilli(1_700_000_000_000)

adapter, err := linear.New(context.Background(), linear.Options{
WebhookSecret: "whsec",
ClientCredentials: linear.ClientCredentials{ClientID: "client", ClientSecret: "secret"},
APIBaseURL: api.URL,
Client: api.Client(),
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("new adapter: %v", err)
}
bot, err := chat.New(context.Background(),
chat.WithState(memory.New()),
chat.WithAdapter(adapter),
chat.WithRuntimeOptions(chat.RuntimeOptions{
DedupeTTL: time.Hour,
ThreadLockTTL: time.Hour,
Dispatch: chat.DispatchDeferred,
MaxDetached: 1,
DetachTimeout: 5 * time.Second,
}),
)
if err != nil {
t.Fatalf("new runtime: %v", err)
}
defer func() { _ = bot.Shutdown(context.Background()) }()

var wg sync.WaitGroup
wg.Add(1)
release := make(chan struct{})
bot.OnNewMention(func(ctx context.Context, ev *chat.MessageEvent) error {
defer wg.Done()
<-release
return nil
})

handler, err := bot.Webhook("linear")
if err != nil {
t.Fatalf("webhook: %v", err)
}
serve := func(body string) *httptest.ResponseRecorder {
bodyBytes := []byte(body)
req := httptest.NewRequest(http.MethodPost, "/linear", bytes.NewReader(bodyBytes))
signLinearRequest(req, "whsec", bodyBytes)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}

// First delivery saturates MaxDetached=1.
if rec := serve(createdPayload(now, "C1", "hello", "U1", "User One", "APP1")); rec.Code != http.StatusOK {
t.Fatalf("first delivery status = %d, body = %s", rec.Code, rec.Body.String())
}
// Second delivery hits the Admission Bound: retry-inducing 503, so
// Linear's redelivery covers the event.
rec := serve(createdPayloadForOrganization(now, "ORG1", "C2", "hello again", "U1", "User One", "APP1"))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("saturated delivery status = %d, want 503 (body = %s)", rec.Code, rec.Body.String())
}

close(release)
wg.Wait()
}
1 change: 1 addition & 0 deletions adapters/linear/deferred_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func TestDeferredDispatchPostsFromDetachedTail(t *testing.T) {
DedupeTTL: time.Hour,
ThreadLockTTL: time.Hour,
Dispatch: chat.DispatchDeferred,
MaxDetached: 1024,
DetachTimeout: 5 * time.Second,
}),
)
Expand Down
8 changes: 8 additions & 0 deletions adapters/linear/linear.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,14 @@ func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler {
}
if ok {
if err := dispatch(r.Context(), event); err != nil {
if errors.Is(err, chat.ErrAdmissionRejected) {
// Linear redelivers webhooks on non-2xx: a retry-inducing
// status lets the platform's own retry cover the event,
// and because the delivery was never dedupe-marked that
// retry is not deduped away.
http.Error(w, "linear runtime at capacity", http.StatusServiceUnavailable)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Expand Down
113 changes: 113 additions & 0 deletions adapters/slack/admission_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package slack_test

import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"

"github.com/coder/chat"
"github.com/coder/chat/adapters/slack"
)

// newAdmissionTestAdapter builds a single-tenant adapter with static identity
// so webhook shapes reach dispatch without any Slack API traffic.
func newAdmissionTestAdapter(t *testing.T, now time.Time) *slack.Adapter {
t.Helper()
adapter, err := slack.New(context.Background(), slack.Options{
SigningSecret: "secret",
BotToken: "xoxb-test",
TeamID: "T1",
BotUserID: "UBOT",
BotID: "BBOT",
Now: func() time.Time { return now },
})
if err != nil {
t.Fatalf("new slack adapter: %v", err)
}
return adapter
}

// rejectingDispatch simulates the runtime's Admission Bound rejection (ADR
// 0015), wrapped the way dispatch wraps its sentinel.
func rejectingDispatch(context.Context, *chat.Event) error {
return fmt.Errorf("dispatch: %w", chat.ErrAdmissionRejected)
}

// TestEventAdmissionRejectionAnswersRetryStatus pins the Events API mapping:
// Slack redelivers event callbacks, so an admission rejection answers a
// retry-inducing 503 instead of acknowledging work the runtime refused.
func TestEventAdmissionRejectionAnswersRetryStatus(t *testing.T) {
t.Parallel()

now := time.Unix(1_700_000_000, 0)
handler := newAdmissionTestAdapter(t, now).Webhook(rejectingDispatch)

rec := serveSignedSlackWebhook(t, handler, now, `{
"type":"event_callback",
"team_id":"T1",
"event_id":"Ev1",
"event":{
"type":"app_mention",
"channel":"C1",
"user":"U1",
"text":"<@UBOT> hi",
"ts":"111.000"
}
}`, "", "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("event admission rejection status = %d, want 503 (body = %s)", rec.Code, rec.Body.String())
}
}

// TestCommandAdmissionRejectionAnswersBusySignal pins the slash-command
// mapping: Slack does not redeliver commands, so the shape's ack body carries
// a truthful, user-visible busy message instead of a retry-inducing status.
func TestCommandAdmissionRejectionAnswersBusySignal(t *testing.T) {
t.Parallel()

now := time.Unix(1_700_000_000, 0)
handler := newAdmissionTestAdapter(t, now).Webhook(rejectingDispatch)

form := url.Values{}
form.Set("command", "/deploy")
form.Set("team_id", "T1")
form.Set("channel_id", "C1")
form.Set("user_id", "U1")
form.Set("trigger_id", "trig")
rec := serveSignedSlackForm(t, handler, now, form)
if rec.Code != http.StatusOK {
t.Fatalf("command admission rejection status = %d, want 200 with a busy body (body = %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "at capacity") || !strings.Contains(body, "was not run") {
t.Fatalf("command admission rejection body = %q, want a truthful busy signal", body)
}
}

// TestInteractionAdmissionRejectionAnswersRetryStatus pins the block_actions
// mapping: the ack body is not rendered to the user, so the truthful signal is
// a non-2xx ack Slack surfaces as a visible failure on the component.
func TestInteractionAdmissionRejectionAnswersRetryStatus(t *testing.T) {
t.Parallel()

now := time.Unix(1_700_000_000, 0)
handler := newAdmissionTestAdapter(t, now).Webhook(rejectingDispatch)

rec := serveSignedSlackInteractivity(t, handler, now, `{
"type":"block_actions",
"team":{"id":"T1"},
"user":{"id":"U1"},
"channel":{"id":"C1"},
"container":{"channel_id":"C1","message_ts":"333.000"},
"trigger_id":"trigger-999",
"response_url":"https://hooks.slack.com/actions/T1/999",
"actions":[{"action_id":"approve","block_id":"b1","value":"yes","type":"button"}]
}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("interaction admission rejection status = %d, want 503 (body = %s)", rec.Code, rec.Body.String())
}
}
26 changes: 26 additions & 0 deletions adapters/slack/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,29 @@ func (a *Adapter) handleCommandForm(w http.ResponseWriter, r *http.Request, disp
return
}
if err := dispatch(r.Context(), event); err != nil {
if errors.Is(err, chat.ErrAdmissionRejected) {
// Slack does not redeliver slash commands, so a retry-inducing
// status would lose the invocation with only a generic Slack error
// shown. The shape's acknowledgement contract renders the ack body
// as an ephemeral message to the invoking user, so answer 200 with
// a truthful busy signal instead — never a silent failure, never a
// fake success.
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(commandBusyMessage))
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

// commandBusyMessage is the truthful busy signal a slash command invoker sees
// when the runtime's Admission Bound rejects the invocation (ADR 0015). It
// renders as the command's ephemeral response.
const commandBusyMessage = "The bot is at capacity right now. Your command was not run — please try again in a moment."

// normalizeCommand decodes and validates a slash command into a Command Event. A
// missing required field or a cross-tenant team is a 400, like a malformed event.
func (a *Adapter) normalizeCommand(r *http.Request, form url.Values) (*chat.Event, error) {
Expand Down Expand Up @@ -226,6 +243,15 @@ func (a *Adapter) handleInteractionForm(w http.ResponseWriter, r *http.Request,
return
}
if err := dispatch(r.Context(), event); err != nil {
if errors.Is(err, chat.ErrAdmissionRejected) {
// Slack does not redeliver block_actions, and the shape's ack body
// is not rendered to the user; a non-2xx ack is the truthful
// signal the shape supports — Slack surfaces the failed delivery
// as a visible warning on the activated component, and the
// rejection stays observable through Runtime Observation.
http.Error(w, "slack runtime at capacity", http.StatusServiceUnavailable)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Expand Down
8 changes: 8 additions & 0 deletions adapters/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,14 @@ func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler {
}
if ok {
if err := dispatch(r.Context(), event); err != nil {
if errors.Is(err, chat.ErrAdmissionRejected) {
// Events API callbacks are platform-redelivered: a
// retry-inducing status lets Slack's own retry cover
// the event, and because the delivery was never
// dedupe-marked that retry is not deduped away.
http.Error(w, "slack runtime at capacity", http.StatusServiceUnavailable)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Expand Down
Loading