diff --git a/docs/plugins.md b/docs/plugins.md
index 6adce7e..6cc1ef2 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -1360,6 +1360,13 @@ for `!assist`. The no-answer search link requests it too. This avoids relying on
the normal search page's automatic answer-display setting, but is not a public
Search Assist API, a challenge bypass, or a guarantee of an answer for every query.
+Initial lightweight attempts are spaced by 300 ms. If all providers miss, GoBot
+waits 500 ms and makes one final lightweight request for the original question
+within the same command. Requests with at least 12 seconds remaining reserve
+three seconds for this recovery step; shorter deadlines are not shortened.
+Successful answers return immediately without the final retry. All waits and
+requests respect the overall timeout, and failed lookups are not cached.
+
The plugin is configured under `plugins.ask`:
~~~yaml
diff --git a/plugins/ask.go b/plugins/ask.go
index 10c73ee..df091cc 100644
--- a/plugins/ask.go
+++ b/plugins/ask.go
@@ -243,6 +243,35 @@ type askSource struct {
}
func (p *Ask) findSource(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) {
+ if !cfg.Bool("search_assist_enabled", true) {
+ return p.findSourceOnce(ctx, question, cfg)
+ }
+ lookupCtx, cancel := askInitialLookupContext(ctx)
+ source, ok := p.findSourceOnce(lookupCtx, question, cfg)
+ cancel()
+ if ok || ctx.Err() != nil {
+ return source, ok
+ }
+ // An answer may become available after the initial requests. Revisit the
+ // original question within this command, not only when the user asks again.
+ retryCtx, retryCancel := context.WithTimeout(ctx, 3*time.Second)
+ defer retryCancel()
+ if !askWaitForRetry(retryCtx, 500*time.Millisecond) {
+ return askSource{}, false
+ }
+ return askDuckDuckGoSearchAssistOnce(retryCtx, question, duckDuckGoSearchAssistURL(question))
+}
+
+func askInitialLookupContext(ctx context.Context) (context.Context, context.CancelFunc) {
+ // Reserve a recovery window on normal request budgets. Short user-defined
+ // deadlines retain the existing provider budget rather than being squeezed.
+ if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) >= 12*time.Second {
+ return context.WithDeadline(ctx, deadline.Add(-3*time.Second))
+ }
+ return context.WithCancel(ctx)
+}
+
+func (p *Ask) findSourceOnce(ctx context.Context, question string, cfg bot.PluginConfig) (askSource, bool) {
focused := askFocusedTerm(question)
webResultTried := false
var deferredWebResult askSource
@@ -776,6 +805,9 @@ func askDuckDuckGoSearchAssist(ctx context.Context, question string) (askSource,
// and request context limits.
queries := askSearchAssistQueryVariants(question)
for attempt := 0; attempt < 2; attempt++ {
+ if attempt > 0 && !askWaitForRetry(ctx, 300*time.Millisecond) {
+ break
+ }
query := queries[0]
if attempt < len(queries) {
query = queries[attempt]
@@ -790,6 +822,20 @@ func askDuckDuckGoSearchAssist(ctx context.Context, question string) (askSource,
return askSource{}, false
}
+func askWaitForRetry(ctx context.Context, delay time.Duration) bool {
+ if ctx.Err() != nil {
+ return false
+ }
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return false
+ case <-timer.C:
+ return ctx.Err() == nil
+ }
+}
+
func askSearchAssistQueryVariants(question string) []string {
original := strings.TrimSpace(question)
queries := []string{original}
diff --git a/plugins/ask_resilience_test.go b/plugins/ask_resilience_test.go
index 7a4bb6b..7a601d4 100644
--- a/plugins/ask_resilience_test.go
+++ b/plugins/ask_resilience_test.go
@@ -103,6 +103,121 @@ func TestAskExplicitAssistURL(t *testing.T) {
}
}
+func TestAskRecoversDelayedAnswerInOriginalCommand(t *testing.T) {
+ old := askHTTPClient
+ t.Cleanup(func() { askHTTPClient = old })
+ for _, question := range []string{"what kind of GoKart does Mario use?", "what kind of GoKart does Toad use?"} {
+ t.Run(question, func(t *testing.T) {
+ started := time.Now()
+ pages := 0
+ askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Path == "/assist.js" {
+ return newPluginResponse(200, `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"The available kart depends on the game.","sources":[{"article":{"link":"https://example.org/karts"}}]}}]};`), nil
+ }
+ pages++
+ if r.URL.Query().Get("q") != question {
+ t.Fatal("recovery changed the original question")
+ }
+ if pages <= 2 || time.Since(started) < 750*time.Millisecond {
+ return newPluginResponse(200, `No generated answer yet`), nil
+ }
+ return newPluginResponse(200, ``), nil
+ })}
+ ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
+ defer cancel()
+ cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "duckduckgo_enabled": false, "wikidata_fallback": false, "search_results_enabled": false}
+ source, ok := (&Ask{}).findSource(ctx, question, cfg)
+ if !ok || source.Provider != "search_assist" || pages != 3 {
+ t.Fatalf("original command did not recover: source=%+v ok=%v requests=%d", source, ok, pages)
+ }
+ })
+ }
+}
+
+func TestAskRecoveryIsBoundedAndCancelable(t *testing.T) {
+ old := askHTTPClient
+ t.Cleanup(func() { askHTTPClient = old })
+ for _, timeout := range []time.Duration{50 * time.Millisecond, 2 * time.Second} {
+ t.Run(timeout.String(), func(t *testing.T) {
+ pages := 0
+ askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
+ if r.Context().Err() != nil {
+ t.Fatal("request made after cancellation")
+ }
+ pages++
+ return newPluginResponse(200, `No answer`), nil
+ })}
+ ctx, cancel := context.WithTimeout(t.Context(), timeout)
+ defer cancel()
+ cfg := bot.PluginConfig{"search_assist_browser_enabled": false, "duckduckgo_enabled": false, "wikidata_fallback": false, "search_results_enabled": false}
+ if _, ok := (&Ask{}).findSource(ctx, "what kind of kart?", cfg); ok {
+ t.Fatal("fabricated an answer from an empty response")
+ }
+ want := 3
+ if timeout < time.Second {
+ want = 1
+ }
+ if pages != want {
+ t.Fatalf("requests=%d want=%d", pages, want)
+ }
+ })
+ }
+}
+
+func TestAskSuccessDoesNotTriggerRecovery(t *testing.T) {
+ old := askHTTPClient
+ t.Cleanup(func() { askHTTPClient = old })
+ requests := 0
+ askHTTPClient = &http.Client{Transport: newPluginRoundTripper(func(r *http.Request) (*http.Response, error) {
+ requests++
+ if r.URL.Path == "/assist.js" {
+ return newPluginResponse(200, `DDG.deep.deepPayload={"instantAnswers":[{"data":{"answer":"An existing successful answer."}}]};`), nil
+ }
+ return newPluginResponse(200, ``), nil
+ })}
+ if _, ok := (&Ask{}).findSource(t.Context(), "a question", bot.PluginConfig{}); !ok || requests != 2 {
+ t.Fatalf("successful path changed: ok=%v requests=%d", ok, requests)
+ }
+}
+
+func TestAskReservesRecoveryBudgetWithoutExtendingDeadline(t *testing.T) {
+ for _, budget := range []time.Duration{8 * time.Second, 20 * time.Second} {
+ parent, cancel := context.WithTimeout(t.Context(), budget)
+ ctx, done := askInitialLookupContext(parent)
+ parentDeadline, _ := parent.Deadline()
+ deadline, _ := ctx.Deadline()
+ want := time.Duration(0)
+ if budget == 20*time.Second {
+ want = 3 * time.Second
+ }
+ if parentDeadline.Sub(deadline) != want {
+ t.Errorf("budget=%s reserved=%s want=%s", budget, parentDeadline.Sub(deadline), want)
+ }
+ done()
+ if parent.Err() != nil {
+ t.Fatal("canceling initial lookup canceled recovery parent")
+ }
+ cancel()
+ }
+}
+
+func TestLiveAskKartQuestions(t *testing.T) {
+ if os.Getenv("GOBOT_LIVE_LOOKUPS") != "1" {
+ t.Skip("opt-in live provider smoke test")
+ }
+ for _, question := range []string{"what kind of GoKart does Mario use?", "what kind of GoKart does Toad use?"} {
+ t.Run(question, func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
+ defer cancel()
+ source, ok := (&Ask{}).findSource(ctx, question, bot.PluginConfig{})
+ if !ok {
+ t.Fatal("no answer within the original request")
+ }
+ t.Logf("provider=%s source=%s", source.Provider, source.URL)
+ })
+ }
+}
+
func TestAskComparisonDoesNotReturnEntityDescription(t *testing.T) {
old := askHTTPClient
t.Cleanup(func() { askHTTPClient = old })