DEV Community

luisgustvo
luisgustvo

Posted on

Build a Controlled AWS WAF Recovery Path for LangChain

A robust agent should not decide that a page “looks like” an AWS WAF challenge. It should receive a typed signal from deterministic code, run only tools approved for that target and purpose, and continue only after the original operation has been verified.

This tutorial builds that boundary around LangChain or LangGraph. The examples are for systems you own or are explicitly authorized to automate.

What AWS WAF changes in an agent workflow

AWS documents two response patterns that matter here:

  • Challenge: HTTP 202 with x-amzn-waf-action: challenge
  • CAPTCHA: HTTP 405 with x-amzn-waf-action: captcha

With a valid token, the request continues to the next rule. Without one, a browser may receive a JavaScript interstitial. A successful interaction updates client token state and resubmits the request.

An agent-facing HTTP tool can misread these responses as success, an empty page, or a transient server error. The fix is not more prompting. The host application should classify the response and route it through a bounded recovery path.

Use five separate components

Keep these responsibilities independent:

  1. Agent: selects the next business action from a restricted tool set.
  2. HTTP/browser client: owns cookies, headers, and page state.
  3. Policy gate: checks target, purpose, redirects, and retry budget.
  4. Challenge adapter: exposes only documented capabilities needed by the recovery node.
  5. Verifier: repeats the intended action and checks an application-owned success condition.

CapSolver’s agent tools guide describes capsolver-agent as an adapter over capsolver-core. Its LangChain path supplies framework-compatible tools through get_langchain_tools(). That lets application code decide when a tool can run while the model sees only a narrow contract.

Prepare an authorized test environment

Define the boundary before installing packages:

  • allowlisted hostnames you own or have explicit permission to test;
  • approved purposes such as QA validation;
  • a secret store for model and CapSolver credentials;
  • one session owner for detection, recovery, retry, and verification;
  • a small retry and total-time budget;
  • a specific success assertion;
  • a review route for unknown signals or state-changing actions;
  • redaction rules for tokens, cookies, credentials, and page data.

Set up an isolated environment:

python -m venv .venv
source .venv/bin/activate

pip install git+https://github.com/capsolver-ai/capsolver-core.git
pip install "capsolver-agent[langchain] @ git+https://github.com/capsolver-ai/capsolver-agent.git"
pip install langchain-openai langgraph playwright

playwright install chromium
Enter fullscreen mode Exit fullscreen mode

Load credentials from the environment for local development:

export CAPSOLVER_API_KEY="set-this-in-your-secret-manager"
export OPENAI_API_KEY="set-this-in-your-secret-manager"
Enter fullscreen mode Exit fullscreen mode

Do not put real values in prompts, traces, notebooks, issues, or graph checkpoints. Use a managed secret store in deployed environments.

Classify the WAF response outside the model

Require the documented header and status together:

from dataclasses import dataclass
from typing import Mapping, Literal

WafAction = Literal["challenge", "captcha", "none", "unknown"]


@dataclass(frozen=True)
class WafSignal:
    action: WafAction
    status_code: int
    needs_review: bool = False


def classify_aws_waf_response(
    status_code: int,
    headers: Mapping[str, str],
) -> WafSignal:
    normalized = {key.lower(): value.lower() for key, value in headers.items()}
    action = normalized.get("x-amzn-waf-action", "")

    if action == "challenge" and status_code == 202:
        return WafSignal(action="challenge", status_code=status_code)
    if action == "captcha" and status_code == 405:
        return WafSignal(action="captcha", status_code=status_code)
    if action in {"challenge", "captcha"}:
        return WafSignal(
            action="unknown",
            status_code=status_code,
            needs_review=True,
        )
    return WafSignal(action="none", status_code=status_code)
Enter fullscreen mode Exit fullscreen mode

A 202 by itself may be a valid asynchronous response. A 405 by itself may simply reject the method. Mismatched signals should lead to review.

AWS notes that cross-origin browser JavaScript cannot read x-amzn-waf-action through CORS. In that case, classify the network response in the automation layer or use an owned same-origin integration; do not infer the result from page text.

Add a deterministic authorization gate

The model should never expand the list of allowed targets:

from dataclasses import dataclass
from urllib.parse import urlparse


@dataclass(frozen=True)
class PolicyDecision:
    allowed: bool
    reason: str


ALLOWED_HOSTS = {"staging.example.com", "research.example.org"}
ALLOWED_PURPOSES = {"qa-validation", "authorized-research"}


def authorize_recovery(
    url: str,
    purpose: str,
    attempts: int,
) -> PolicyDecision:
    host = (urlparse(url).hostname or "").lower()

    if host not in ALLOWED_HOSTS:
        return PolicyDecision(False, "host-not-allowed")
    if purpose not in ALLOWED_PURPOSES:
        return PolicyDecision(False, "purpose-not-allowed")
    if attempts >= 2:
        return PolicyDecision(False, "retry-budget-exhausted")
    return PolicyDecision(True, "authorized")
Enter fullscreen mode Exit fullscreen mode

In production, read this configuration from a reviewed, versioned source. Validate redirects too, and record only non-sensitive policy metadata.

Register the smallest CapSolver tool set

The documented adapter can create LangChain-compatible tools:

import os
from capsolver_agent.langchain import get_langchain_tools


capsolver_tools = get_langchain_tools(
    api_key=os.environ["CAPSOLVER_API_KEY"],
)
Enter fullscreen mode Exit fullscreen mode

LangChain and LangGraph construction APIs evolve, so isolate this wiring and pin versions that pass your integration tests. Do not give every agent every tool. Make challenge handling available only from the recovery subgraph after authorization succeeds.

CapSolver documents mappings for solve_captcha, detect_captchas, and solve_on_page, backed by corresponding core capabilities. Choose the smallest appropriate capability. A browser-oriented flow can preserve page context without exposing raw solution data to the model.

Keep the browser out of graph state

AWS WAF token documentation explains that Challenge and CAPTCHA actions use client tokens to track completed interactions. Starting a new browser between detection and retry can discard that state.

Store browser objects in an application-owned registry:

class BrowserRegistry:
    def __init__(self) -> None:
        self._pages: dict[str, object] = {}

    def register(self, page_id: str, page: object) -> None:
        self._pages[page_id] = page

    def get(self, page_id: str) -> object:
        if page_id not in self._pages:
            raise KeyError("browser page is not registered")
        return self._pages[page_id]

    async def close(self, page_id: str) -> None:
        page = self._pages.pop(page_id, None)
        if page is not None:
            await page.close()
Enter fullscreen mode Exit fullscreen mode

Graph state needs only the opaque page_id, current URL, purpose, attempt count, and typed status. Exclude cookies, storage, tokens, API keys, raw HTML, and browser objects.

Route on typed results

Use a compact state contract:

from typing import Literal, TypedDict

RecoveryStatus = Literal[
    "not_needed",
    "authorized",
    "resolved",
    "retry",
    "review",
    "denied",
]


class RecoveryState(TypedDict, total=False):
    request_id: str
    purpose: str
    current_url: str
    page_id: str
    attempts: int
    waf_action: str
    recovery_status: RecoveryStatus
    error_code: str | None
    final_assertion_passed: bool


def route_after_detection(state: RecoveryState) -> str:
    if state.get("waf_action") not in {"challenge", "captcha"}:
        return "continue"
    if state.get("recovery_status") == "authorized":
        return "recover"
    if state.get("recovery_status") in {"denied", "review"}:
        return "human_review"
    return "authorize"


def route_after_recovery(state: RecoveryState) -> str:
    status = state.get("recovery_status")
    if status == "resolved":
        return "verify"
    if status == "retry":
        return "authorize"
    return "human_review"
Enter fullscreen mode Exit fullscreen mode

The recovery node may call the approved tool, but it should return only a typed status and stable error code—not the raw tool response.

Verify the original operation

Challenge completion is not proof that the business action succeeded. Repeat the intended operation in the same session and assert something your application controls:

async def verify_expected_page(page, expected_url_prefix: str) -> bool:
    await page.wait_for_load_state("domcontentloaded")

    if not page.url.startswith(expected_url_prefix):
        return False

    marker = page.get_by_test_id("authorized-content")
    try:
        await marker.wait_for(state="visible", timeout=15_000)
        return True
    except Exception:
        return False
Enter fullscreen mode Exit fullscreen mode

Prefer a stable test ID, expected API response, or known state transition. Avoid broad text checks because error pages often contain plausible-looking words.

If verification fails, reclassify the current response and inspect session state. Do not immediately invoke another recovery attempt.

Bound retries, logs, and monitoring

Route known, authorized signals to recovery. Route mismatches, unexpected redirects, missing sessions, and exhausted budgets to review or denial. A transient timeout may receive one retry if the total budget allows it; there should be no unbounded loop.

Log events such as waf_signal_detected, policy_allowed, recovery_started, recovery_finished, and page_verified. Include an internal request ID, hostname, duration, attempt number, and stable error code. Exclude credentials, cookies, tokens, raw challenge data, and sensitive page content.

AWS exposes CloudWatch metrics for Challenge and CAPTCHA activity in its WAF metrics reference. Correlate agent decisions and infrastructure metrics using an internal request ID—not a credential or token.

Test the failure paths

Before production, test:

  1. normal response without a challenge;
  2. documented Challenge signal;
  3. documented CAPTCHA signal;
  4. header/status mismatch;
  5. unapproved hostname;
  6. redirect outside the allowlist;
  7. missing browser session;
  8. tool timeout;
  9. failed final assertion;
  10. retry-budget exhaustion.

Unit-test the classifier, policy gate, and verifier. Use credentialed end-to-end tests only on an approved environment. Add explicit assertions that logs contain no credentials, cookies, or token values.

The architecture in one line

The dependable sequence is:

detect → authorize → recover → retry original action → verify → continue/review
Enter fullscreen mode Exit fullscreen mode

For authorized automation, CapSolver supplies documented agent and core layers for LangChain integrations. Your application should still own policy, secrets, session state, retries, and the final assertion.

Use the current CapSolver documentation when implementing the integration, and do not guess API fields from outdated snippets.

Top comments (0)