Bramka MCP, która nie pada z jednym backendem — timeouty, circuit breaker i błędy dla agenta

przez Łukasz | lip 1, 2026

Szacowany czas czytania: ~7 minut. Kod: breaker.go, resilience.go w repozytorium projektu.

To ostatni wpis z tego wątku o bramce MCP. Przeszliśmy drogę: serwer MCP w WordPressie, potem wyciągnięcie go do bramki w Go, uwierzytelnienie i fan-out do wielu backendów. Fan-out dał nam jedną furtkę do wielu systemów — i dokładnie tu pojawia się nowe ryzyko: jeden chory backend może zatruć całą bramkę.

Ten wpis domyka temat: jak sprawić, żeby wolna albo padnięta analityka nie blokowała zapisu notatek, i jak zwracać błędy tak, żeby agent umiał je obejść.

Awaria, która kaskaduje

Wyobraź sobie, że backend analityki zaczyna odpowiadać po 30 sekundach zamiast po 200 ms. Bez zabezpieczeń dzieje się to:

  1. Agent woła analytics_top_pages. Bramka czeka 30 s.
  2. Gorutyna wisi, połączenie wisi, agent wisi.
  3. Przy kilku takich wywołaniach naraz zasoby bramki się kończą.
  4. Nagle także create_note (WordPress, zdrowy!) zaczyna zwalniać.

Jeden backend pociągnął za sobą resztę. To jest cena fan-outu, jeśli nie dołożysz izolacji. Potrzebujemy dwóch rzeczy: twardego limitu czasu na każde wywołanie i bezpiecznika, który odetnie chory backend, zanim ten wykończy bramkę.

Timeout per backend

Najprostszy i najważniejszy krok. Żadne wywołanie backendu nie może wisieć w nieskończoność. W Go to kontekst z deadline’em:

go
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
stats, err := ac.TopPages(cctx, days, limit)

Kiedy backend nie wyrobi się w czasie, ctx zostaje anulowany, TopPages zwraca context.DeadlineExceeded, a bramka idzie dalej. Dobierz timeout osobno per backend — analityka „tylko do odczytu” może mieć krótszy budżet niż zapis do WordPressa.

Circuit breaker — nie pukaj do martwych drzwi

Timeout chroni pojedyncze wywołanie, ale gdy backend leży, każde kolejne żądanie i tak czeka pełne 3 sekundy, zanim padnie. Przy setce wywołań to setki straconych sekund. Circuit breaker rozwiązuje to tak: po serii błędów „otwiera obwód” i przez chwilę odrzuca wywołania natychmiast, nie dotykając backendu. Potem ostrożnie sprawdza, czy ten wrócił.

Trzy stany:

  • closed — normalna praca; liczymy błędy pod rząd.
  • open — po przekroczeniu progu odrzucamy wszystko od razu (ErrBreakerOpen).
  • half-open — po czasie karencji przepuszczamy jedną próbę. Sukces → wracamy do closed. Porażka → znów open.

Rdzeń jest krótki:

go
func (b *Breaker) Do(fn func() error) error {
    if !b.allow() {        // open i przed karencją? -> odrzuć natychmiast
        return ErrBreakerOpen
    }
    err := fn()
    b.record(err)          // sukces zeruje licznik, błąd może otworzyć obwód
    return err
}

Przejścia stanów pokrywają testy (przekroczenie progu otwiera obwód, otwarty obwód odrzuca bez wołania backendu, po karencji jedna próba zamyka lub znów otwiera). W produkcji nie musisz pisać tego sam — sięgnij po utrzymywaną bibliotekę jak github.com/sony/gobreaker. W artykule implementacja jest ręczna, żeby było widać mechanikę.

Kluczowe: breaker jest per backend. Osobny bezpiecznik dla analityki, osobny dla WordPressa. Otwarty obwód analityki nie ma prawa dotknąć create_note. To ta sama zasada izolacji, co przy sekretach z poprzedniego wpisu — tylko na poziomie dostępności.

Guard — timeout i breaker w jednym

W praktyce łączysz oba mechanizmy w jednym wywołaniu. Guard nakłada timeout i owija całość breakerem:

go
func Guard[T any](ctx context.Context, b *Breaker, timeout time.Duration,
    fn func(context.Context) (T, error)) (T, error) {

    cctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()
    var out T
    err := b.Do(func() error {
        var e error
        out, e = fn(cctx)
        return e
    })
    // ...
}

Użycie w toolu jest teraz czyste — cała odporność w jednej linijce:

go
stats, err := Guard(ctx, analyticsBreaker, 3*time.Second, func(c context.Context) ([]PageStat, error) {
    return ac.TopPages(c, days, limit)
})

Powtarzające się timeouty liczą się jako błędy, więc backend, który stale nie wyrabia, sam otworzy obwód — i przestaniemy w niego pukać.

Błąd, który agent zrozumie

Tu jest różnica między bramką „techniczną” a bramką dla agentów. Kiedy backend padnie, nie zwracaj modelowi surowego dial tcp: connection refused. Zwróć komunikat, z którego model wyciągnie wniosek i zaproponuje obejście:

go
func friendlyBackendError(backend string, err error) string {
    switch {
    case errors.Is(err, ErrBreakerOpen):
        return fmt.Sprintf("Backend %q jest chwilowo niedostępny. "+
            "Spróbuj ponownie za chwilę lub użyj innej funkcji.", backend)
    case errors.Is(err, context.DeadlineExceeded):
        return fmt.Sprintf("Backend %q nie odpowiedział na czas. "+
            "Możesz spróbować ponownie lub zawęzić zapytanie.", backend)
    default:
        return fmt.Sprintf("Backend %q zwrócił błąd: %v", backend, err)
    }
}

I ważny szczegół z wcześniejszych wpisów: zwracaj to jako wynik toola z IsError=true, a nie jako błąd protokołu. Wtedy model widzi treść błędu i może się poprawić — na przykład sięgnąć po create_note, skoro analityka leży. To jest graceful degradation: bramka nie udaje, że wszystko działa, ale też nie wywraca całej rozmowy.

Bonus: stan bezpieczników w /healthz

Skoro breaker zna stan każdego backendu, wystaw to w health-checku — od razu widać, który backend kuleje:

go
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
    fmt.Fprintf(w, "wordpress=%s analytics=%s\n",
        wpBreaker.State(), analyticsBreaker.State())
})

Twój monitoring dostaje sygnał, zanim użytkownik zgłosi problem.

Domknięcie serii

Zbudowaliśmy kompletną drogę:

  1. Serwer MCP w WordPressie — najprostsza forma, tools w PHP przy bazie.
  2. Bramka w Go — osobny proces, jawna granica zaufania, sekrety poza CMS-em.
  3. Auth — statyczny Bearer na start, OAuth 2.0 gdy trzeba granularności.
  4. Fan-out — jedna bramka, wiele backendów, osobne poświadczenia i scope.
  5. Odporność — timeouty, circuit breaker per backend, błędy dla agenta.

Każdy krok odpowiadał na to samo pytanie z innej strony: gdzie to się wykonuje i komu ufamy. Bramka nie jest „lepsza” od pluginu — jest inną odpowiedzią, która zaczyna wygrywać dokładnie wtedy, gdy pojawia się drugi backend, wymóg izolacji albo skala. Masz teraz obie opcje w ręku i framework, żeby wybrać świadomie.


Pojęcia ze słownika: MCP · Circuit breaker · Graceful degradation · Observability

Kod z tego wpisu: breaker.go (implementacja + testy) i resilience.go (Guard, friendlyBackendError, odporny tool)

Table of Contents