{"id":25568,"date":"2026-09-07T02:19:33","date_gmt":"2026-09-07T02:19:33","guid":{"rendered":"https:\/\/capskip.com\/?p=25568"},"modified":"2026-09-07T02:19:33","modified_gmt":"2026-09-07T02:19:33","slug":"temporal-captcha-workflow","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/temporal-captcha-workflow\/","title":{"rendered":"\u5982\u4f55\u5728 Temporal \u5de5\u4f5c\u6d41\u7684 Activity \u4e2d\u8bc6\u522b\u9a8c\u8bc1\u7801"},"content":{"rendered":"<p>A Temporal captcha solve goes in an Activity. Not in the workflow method, not in a helper the workflow calls, in an Activity. Workflow code is replayed from history every time the workflow resumes, so it has to be deterministic: no network calls, no randomness, no clock reads. A solve is all three of those things at once. Once it is in an Activity the rest is a retry policy and one piece of timing discipline, and the whole thing is about forty lines.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>Python 3.10 or newer, the Temporal Python SDK and the CapSkip Python SDK.<\/li>\n<li>A Temporal Service to connect to. A local dev server or Temporal Cloud both work here.<\/li>\n<li>The page URL of the protected form, and its sitekey.<\/li>\n<li>CapSkip in Local mode when the worker and the solver share a machine, or Server mode when they do not. Both are described under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<\/ul>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># pip install temporalio\r\npip install -U temporalio capskip httpx\r\n\r\n# A local service to develop against.\r\ntemporal server start-dev<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Why the solve cannot live in workflow code<\/h2>\n<p>Temporal replays a workflow&#8217;s history to rebuild its state after a worker restart, a deploy or a week-long sleep. For that to produce the same answer twice, workflow code must be deterministic. The SDK is explicit about what that rules out: no network IO, no threading, no randomness, no external calls to processes, no global state mutation. It even runs workflow code in a sandbox that reimports modules per run, which is why activity imports get wrapped in a pass-through block.<\/p>\n<p>A CAPTCHA solve breaks the rule three times over. It is a network call, the token it returns is different on every attempt, and how long it takes depends on the machine. Put it in the workflow method and it will appear to work in development and produce a non-determinism error the first time a worker restarts mid-run.<\/p>\n<p>The good news is that this constraint hands you something. An Activity is retried by Temporal itself, with a policy you declare rather than a loop you write, and its result is recorded in history. So a solve that succeeded is never repeated on a replay, which is exactly what you want for something with a single-use answer.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: the solve activity<\/h2>\n<p>Python&#8217;s CapSkip SDK ships a real asyncio client, not an alias, so an async activity is the natural fit and needs no thread pool. Take the sitekey and the page URL as arguments and return the token.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># pip install capskip\r\nimport os\r\nfrom temporalio import activity\r\nfrom capskip import AsyncCapSkip\r\n\r\n@activity.defn\r\nasync def solve_recaptcha(sitekey: str, page_url: str) -&gt; str:\r\n    solver = AsyncCapSkip(\r\n        host=os.environ.get(&quot;CAPSKIP_HOST&quot;, &quot;127.0.0.1&quot;),\r\n        port=8080,\r\n        apiKey=os.environ.get(&quot;CAPSKIP_API_KEY&quot;, &quot;capskip&quot;),\r\n    )\r\n    result = await solver.recaptcha(sitekey=sitekey, url=page_url)\r\n    return result[&quot;code&quot;]<\/pre>\n<\/div>\n<p>One method covers reCAPTCHA v2, Invisible, Enterprise and v3. The variants are options on the same call rather than separate methods: invisible set to 1, enterprise set to 1, or version set to v3 with an action. Turnstile and GeeTest have their own methods with the same shape, and the full parameter list is in <a href=\"https:\/\/capskip.com\/api-docs\/\">the CapSkip API documentation<\/a>.<\/p>\n<p>If you would rather use the synchronous client, the activity has to be a plain def and the worker needs an activity_executor, because Temporal runs sync activities in a thread pool. The async version above avoids that entirely, which is one of the few places where the Python SDK is genuinely nicer than the others.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: timeouts that are longer than the solver&#8217;s own<\/h2>\n<p>Every activity needs a start_to_close_timeout, and this is where people quietly break their own solves. CapSkip polls for up to 300 seconds on reCAPTCHA, Turnstile and GeeTest, and 120 seconds on image CAPTCHAs. Set the activity timeout below that and Temporal cancels the attempt while the solver is still working, then retries, and you have two solves in flight for one form.<\/p>\n<p>Give the activity headroom over the solver&#8217;s own limit. Six minutes against a five minute solver ceiling is comfortable.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># Longer than recaptchaTimeout, which defaults to 300s.\r\nfrom datetime import timedelta\r\nfrom temporalio.common import RetryPolicy\r\n\r\nSOLVE_TIMEOUT = timedelta(minutes=6)\r\n\r\nSOLVE_RETRIES = RetryPolicy(\r\n    initial_interval=timedelta(seconds=5),\r\n    backoff_coefficient=2.0,\r\n    maximum_attempts=4,\r\n    # These fail identically every time. Do not burn attempts.\r\n    non_retryable_error_types=[&quot;ValidationException&quot;, &quot;ApiException&quot;],\r\n)<\/pre>\n<\/div>\n<p>The non-retryable list is matched by exception class name, and it is worth filling in. ValidationException means a missing or malformed argument, and ApiException means the API rejected the request, usually a sitekey that does not belong to the page URL. Neither improves on the second attempt. NetworkException and TimeoutException are the two that genuinely deserve a retry: the first means the solver is not running or the host is wrong, the second means the solve outlasted the polling timeout. All four derive from a common base, so catching CapSkipError works if you would rather handle everything in one place.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: solve last, not first<\/h2>\n<p>Durable execution makes token expiry easier to get wrong here than in any other orchestrator. A Temporal workflow can wait for a signal, sleep for a day and carry on, and its recorded activity results come back from history unchanged. So a workflow that solves early, waits for an approval, then submits will replay a token that was minted yesterday.<\/p>\n<p>A reCAPTCHA token is accepted once and expires in about two minutes. Order the workflow so the solve is the step immediately before the submit, with nothing that can block in between. The general shape of that problem is covered in the guide to <a href=\"https:\/\/capskip.com\/recaptcha-token-expiration\/\">reCAPTCHA token expiration<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Full working example<\/h2>\n<p>Three activities and a workflow that calls them in order. Read the sitekey, solve, submit. Each activity gets its own timeout and its own retry policy, and each one shows up separately in the Temporal UI, so when something is slow you can see which step it was.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># activities.py\r\nimport os, re, httpx\r\nfrom temporalio import activity\r\nfrom capskip import AsyncCapSkip\r\n\r\n@activity.defn\r\nasync def read_sitekey(page_url: str) -&gt; str:\r\n    async with httpx.AsyncClient(timeout=30) as client:\r\n        html = (await client.get(page_url)).text\r\n    found = re.search(r'data-sitekey=[&quot;\\']([^&quot;\\']+)', html)\r\n    if not found:\r\n        raise RuntimeError(&quot;No data-sitekey on the page.&quot;)\r\n    return found.group(1)\r\n\r\n@activity.defn\r\nasync def solve_recaptcha(sitekey: str, page_url: str) -&gt; str:\r\n    solver = AsyncCapSkip(host=os.environ.get(&quot;CAPSKIP_HOST&quot;, &quot;127.0.0.1&quot;))\r\n    return (await solver.recaptcha(sitekey=sitekey, url=page_url))[&quot;code&quot;]\r\n\r\n@activity.defn\r\nasync def submit_form(page_url: str, token: str) -&gt; int:\r\n    async with httpx.AsyncClient(timeout=30) as client:\r\n        reply = await client.post(\r\n            page_url, data={&quot;g-recaptcha-response&quot;: token}\r\n        )\r\n    return reply.status_code<\/pre>\n<\/div>\n<p>The workflow itself holds no logic beyond the ordering. That is the point: everything that can fail is in an activity, and the workflow is the deterministic part that survives a replay.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># workflow.py\r\nfrom datetime import timedelta\r\nfrom temporalio import workflow\r\nfrom temporalio.common import RetryPolicy\r\n\r\nwith workflow.unsafe.imports_passed_through():\r\n    from activities import read_sitekey, solve_recaptcha, submit_form\r\n\r\n@workflow.defn\r\nclass SubmitProtectedForm:\r\n    @workflow.run\r\n    async def run(self, page_url: str) -&gt; int:\r\n        sitekey = await workflow.execute_activity(\r\n            read_sitekey, page_url,\r\n            start_to_close_timeout=timedelta(seconds=60),\r\n        )\r\n        # Solve directly before the submit. Tokens go stale.\r\n        token = await workflow.execute_activity(\r\n            solve_recaptcha, args=[sitekey, page_url],\r\n            start_to_close_timeout=timedelta(minutes=6),\r\n            retry_policy=RetryPolicy(\r\n                maximum_attempts=4,\r\n                non_retryable_error_types=[&quot;ValidationException&quot;],\r\n            ),\r\n        )\r\n        return await workflow.execute_activity(\r\n            submit_form, args=[page_url, token],\r\n            start_to_close_timeout=timedelta(seconds=60),\r\n        )<\/pre>\n<\/div>\n<p>Note the args list on the two-argument activities. A single positional argument can be passed directly, but more than one has to go through args, and getting that wrong is the most common first error in this SDK.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># worker.py - run this where CapSkip can be reached\r\nimport asyncio\r\nfrom temporalio.client import Client\r\nfrom temporalio.worker import Worker\r\nfrom activities import read_sitekey, solve_recaptcha, submit_form\r\nfrom workflow import SubmitProtectedForm\r\n\r\nasync def main():\r\n    client = await Client.connect(&quot;localhost:7233&quot;)\r\n    worker = Worker(\r\n        client,\r\n        task_queue=&quot;captcha-queue&quot;,\r\n        workflows=[SubmitProtectedForm],\r\n        activities=[read_sitekey, solve_recaptcha, submit_form],\r\n    )\r\n    await worker.run()\r\n\r\nasyncio.run(main())<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Where the worker runs, and where the solver runs<\/h2>\n<p>Temporal splits the two cleanly, and it works in your favour. The Temporal Service schedules work and stores history. It never executes your code. A worker you start inside your own infrastructure holds a long outbound connection to the service and picks tasks off a queue. Nothing inbound is ever opened to your network.<\/p>\n<p>So a worker on the same Windows machine as CapSkip calls 127.0.0.1:8080 exactly as a script on your desk would, and that stays true on Temporal Cloud. The cloud in Temporal Cloud is the orchestration layer, not the compute layer.<\/p>\n<p>The moment the worker moves, the host changes and nothing else does. A worker in a container, on a Linux VM or in Kubernetes cannot reach a Windows solver over loopback, so the solver switches to Server mode. Local mode binds to 127.0.0.1 and answers that device only. Server mode binds to your network or public IP, and a static public IP keeps the address stable. It is still your hardware and still unmetered in both modes, so a workflow that runs ten thousand times a day costs the same as one that runs once.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\"># One environment variable, no code change.\r\n# CAPSKIP_HOST=10.0.0.12 on the worker.\r\nsolver = AsyncCapSkip(host=os.environ[&quot;CAPSKIP_HOST&quot;], port=8080)<\/pre>\n<\/div>\n<p>Turn key validation on once the solver listens on a network address, and give each worker fleet its own key so one can be revoked without touching the others. Both modes are walked through in <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">the CapSkip setup guide<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors and what they mean<\/h2>\n<table>\n<thead>\n<tr>\n<th>What you see<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Non-determinism error on replay<\/td>\n<td>The solve was called from workflow code<\/td>\n<td>Move it into an activity and call it with execute_activity<\/td>\n<\/tr>\n<tr>\n<td>RestrictedWorkflowAccessError on import<\/td>\n<td>The activity module was imported into the sandbox<\/td>\n<td>Import it inside workflow.unsafe.imports_passed_through<\/td>\n<\/tr>\n<tr>\n<td>The activity is cancelled mid-solve, then retried<\/td>\n<td>start_to_close_timeout is shorter than the solver&#8217;s own<\/td>\n<td>Set it above 300 seconds for reCAPTCHA, Turnstile and GeeTest<\/td>\n<\/tr>\n<tr>\n<td>The form rejects a token that looks correct<\/td>\n<td>The workflow blocked between solving and submitting<\/td>\n<td>Make the solve the step immediately before the submit<\/td>\n<\/tr>\n<tr>\n<td>Four attempts burned on the same failure<\/td>\n<td>A deterministic error is being retried<\/td>\n<td>List ValidationException and ApiException as non-retryable<\/td>\n<\/tr>\n<tr>\n<td>NetworkException on every attempt<\/td>\n<td>The worker is not on the machine running the solver<\/td>\n<td>Switch the solver to Server mode and set the host<\/td>\n<\/tr>\n<tr>\n<td>TypeError about arguments on an activity<\/td>\n<td>Two positional arguments were passed directly<\/td>\n<td>Pass them as a list through the args parameter<\/td>\n<\/tr>\n<tr>\n<td>TimeoutException from the SDK<\/td>\n<td>The solve outlasted recaptchaTimeout<\/td>\n<td>Raise it above the default of 300 seconds<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can a Temporal Cloud workflow really call 127.0.0.1?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, because Temporal Cloud does not run your code. Your worker does, wherever you started it, and it connects outward to the service. Loopback on that worker means the worker&#8217;s own machine, so a solver on that machine answers normally. Nothing about that changes when you move from a dev server to Cloud.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Should the solve activity heartbeat?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">It cannot usefully, because the SDK call blocks until the token arrives and there is no point inside it to report from. Give the activity a start_to_close_timeout with real headroom instead, and let a failed attempt be retried by the policy. Heartbeating is for activities that loop over work you control.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">How do I solve a batch without flooding the solver?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Set max_concurrent_activities on the worker, or give solving its own task queue and its own worker with a low cap. That throttles at the place the work is executed, which is more reliable than trying to space out workflow starts. Inside a single process, the async client fans out with asyncio, and that is covered in <a href=\"https:\/\/capskip.com\/solve-captchas-parallel-python\/\">the guide to solving CAPTCHAs in parallel<\/a>.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">How is this different from doing it in Airflow?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Airflow is a scheduler with a DAG, and nothing stops you making a network call while the graph is being built, which is a different set of footguns. Temporal is durable execution, so the constraint is determinism and the answer is always an activity. The solver side is identical in both, and the Airflow version is written up in <a href=\"https:\/\/capskip.com\/airflow-captcha-dag\/\">the Airflow CAPTCHA guide<\/a>.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>Put the solve in an activity, never in the workflow method. Give it a start_to_close_timeout longer than the solver&#8217;s own 300 second ceiling, mark ValidationException and ApiException non-retryable, and order the workflow so the solve is the last thing before the submit. Run the worker where CapSkip is and the host stays 127.0.0.1. The rest of the Python surface is on <a href=\"https:\/\/capskip.com\/python-captcha-solver\/\">the Python CAPTCHA solver page<\/a>, and the same three calls exist in Node.js, PHP and C# as listed on <a href=\"https:\/\/capskip.com\/captcha-solving-sdk\/\">the CAPTCHA solving SDK page<\/a>. The reCAPTCHA options themselves are on <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">the reCAPTCHA v2 solver page<\/a>.<\/p>\n<p>One thing worth knowing before you point a schedule at this. CapSkip is a <a href=\"https:\/\/capskip.com\/\">captcha solver<\/a> that runs on hardware you already own, so a workflow that fires every minute costs the same as one that fires once a week.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Temporal \u7684 workflow \u4ee3\u7801\u5fc5\u987b\u662f\u786e\u5b9a\u6027\u7684\uff0c\u6240\u4ee5\u9a8c\u8bc1\u7801\u8bc6\u522b\u53ea\u80fd\u653e\u5728 Activity \u91cc\u3002\u4e0b\u9762\u662f\u8fd9\u4e2a activity\u3001\u4f1a\u8df3\u8fc7\u90a3\u4e9b\u6c38\u8fdc\u4e0d\u53ef\u80fd\u6210\u529f\u7684\u5931\u8d25\u7684\u91cd\u8bd5\u7b56\u7565\uff0c\u4ee5\u53ca\u4e3a\u4ec0\u4e48\u5728\u6301\u4e45\u5316\u5de5\u4f5c\u6d41\u91cc\uff0ctoken \u8fc7\u671f\u6bd4\u5728\u522b\u7684\u4efb\u4f55\u5730\u65b9\u90fd\u66f4\u5bb9\u6613\u641e\u9519\u3002<\/p>","protected":false},"author":1,"featured_media":25567,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Temporal CAPTCHA: Solve It Inside an Activity | CapSkip","rank_math_description":"A temporal captcha solve belongs in an Activity, never in workflow code. Here is the activity, the retry policy, and how to keep the token fresh.","rank_math_focus_keyword":"temporal captcha","footnotes":""},"categories":[70],"tags":[],"class_list":["post-25568","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25568","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=25568"}],"version-history":[{"count":1,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25568\/revisions"}],"predecessor-version":[{"id":25586,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25568\/revisions\/25586"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/25567"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=25568"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=25568"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=25568"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}