<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vagner Bessa</title>
    <description>The latest articles on DEV Community by Vagner Bessa (@bessavagner).</description>
    <link>https://dev.to/bessavagner</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1268867%2Fd461494a-e880-4fe6-a228-dab47adb7090.png</url>
      <title>DEV Community: Vagner Bessa</title>
      <link>https://dev.to/bessavagner</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bessavagner"/>
    <language>en</language>
    <item>
      <title>Now I can replay it: offline regression testing for multi-turn AI agents</title>
      <dc:creator>Vagner Bessa</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:56:44 +0000</pubDate>
      <link>https://dev.to/bessavagner/now-i-can-replay-it-offline-regression-testing-for-multi-turn-ai-agents-22oa</link>
      <guid>https://dev.to/bessavagner/now-i-can-replay-it-offline-regression-testing-for-multi-turn-ai-agents-22oa</guid>
      <description>&lt;p&gt;ReplayGate is conversation-level regression testing for multi-turn AI agents. The regressions it hunts live &lt;em&gt;between&lt;/em&gt; turns: the agent books before the user confirmed, re-asks for something it was already told, forgets a constraint set three turns back.&lt;/p&gt;

&lt;p&gt;Per-turn assertions look at one reply at a time and sail right past those. So ReplayGate makes a flight recorder's bet: capture a real conversation once, exactly, then replay it offline and assert the cross-turn properties that matter.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://bessavagner.com/building/replaygate/01-recording-an-agent-before-i-can-replay-it/" rel="noopener noreferrer"&gt;update #1&lt;/a&gt; I built the record half: capture an agent's LLM and tool calls into a deterministic fixture, and deliberately defer the part that pays it off. This update is that payoff. I can now replay those recorded conversations offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replaying the recording
&lt;/h2&gt;

&lt;p&gt;Replay re-runs the agent over the fixture's user turns, but the LLM client answers from the recording instead of the network. The match is the same &lt;code&gt;sha256&lt;/code&gt; over &lt;code&gt;(model, system, messages, tools)&lt;/code&gt; from #1, so the agent runs its real logic and only the model and tool results are served from the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;replay_conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rec_llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecordingLLMClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_recording&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rec_tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ToolRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recording&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_recording&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent_factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec_llm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rec_tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ...walk the recorded user turns, calling agent.respond, with no network
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;diff_conversations&lt;/code&gt; then compares the recorded conversation against its replay, turn by turn, on assistant text and tool calls. The CLI wraps both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;replaygate replay ./fx
&lt;span class="go"&gt;replay OK — 2 turns reproduced offline, zero network
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Zero network" is literal: the replay path imports no provider SDK and reads no API key. To prove that rather than assert it, I replayed real recordings with every key unset (&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, and the rest), and they reproduced with an empty diff. A recorder you can't replay blind is just a logger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five providers, one seam
&lt;/h2&gt;

&lt;p&gt;The recorder generalized for almost nothing because of the decision in #1: the agent depends on a one-method &lt;a href="https://bessavagner.com/blog/provider-agnostic-llm-abstraction/" rel="noopener noreferrer"&gt;&lt;code&gt;LLMClient&lt;/code&gt; protocol&lt;/a&gt;, &lt;code&gt;create(model, system, messages, tools)&lt;/code&gt;, not a vendor SDK. A recorder slots in front of the real client, and "the real client" can be anything that satisfies the protocol. So I wrote five. Anthropic goes through its official SDK; OpenAI, OpenRouter, Ollama, and Google Gemini go through one OpenAI-compatible client (the last three are just different base URLs and keys). The SDK imports are lazy, so the core package stays offline and dependency-free, and the test suite still never opens a socket.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;record-live&lt;/code&gt; command points the booking agent at any of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;replaygate record-live booking_happy ./fx &lt;span class="nt"&gt;--provider&lt;/span&gt; ollama &lt;span class="nt"&gt;--model&lt;/span&gt; qwen2.5:7b
&lt;span class="go"&gt;recorded booking_happy live via ollama → ./fx
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part I didn't expect. I recorded the &lt;em&gt;same&lt;/em&gt; two-turn scenario, where the user asks for slots and then says "yes, book 3pm", six times each against Anthropic's &lt;code&gt;claude-opus-4-6&lt;/code&gt;, OpenAI's &lt;code&gt;gpt-5.4&lt;/code&gt;, and a local &lt;code&gt;qwen2.5:7b&lt;/code&gt;. Every run calls &lt;code&gt;search_slots&lt;/code&gt; on turn one. Turn two is where they split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;provider / model&lt;/th&gt;
&lt;th&gt;turn 2, over 6 runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic &lt;code&gt;claude-opus-4-6&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;re-asked for confirmation 6, never booked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI &lt;code&gt;gpt-5.4&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;booked all 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama &lt;code&gt;qwen2.5:7b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;booked 5, searched again 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things fall out of that. Two frontier models read the identical &lt;code&gt;yes, book 3pm&lt;/code&gt; and reach opposite decisions: &lt;code&gt;gpt-5.4&lt;/code&gt; books on every run, &lt;code&gt;claude-opus-4-6&lt;/code&gt; re-asks for confirmation on every run, the same two user turns. And the small local model, &lt;code&gt;qwen2.5:7b&lt;/code&gt;, disagrees with &lt;em&gt;itself&lt;/em&gt;, booking five times and searching again on the sixth. That table is a single batch, not a fixed property: swap one model for another, or just run the same one twice, and the trajectory moves. That is the whole reason to record instead of re-run. As I put it in &lt;a href="https://bessavagner.com/building/replaygate/01-recording-an-agent-before-i-can-replay-it/" rel="noopener noreferrer"&gt;update #1&lt;/a&gt;, you can't diff two runs that never produce the same bytes, so you capture one and replay &lt;em&gt;that&lt;/em&gt;. The recording is the fixed point; the live model isn't.&lt;/p&gt;

&lt;p&gt;To be clear about what this is and isn't: these are recordings, not the diff catching a regression yet. But it's the exact signal the cross-turn checks exist for. Pin the trajectory you want, change the model or the prompt, replay, and a divergence is a regression you'd otherwise meet in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still can't do
&lt;/h2&gt;

&lt;p&gt;The deferral to name plainly: replay today proves &lt;em&gt;faithful reproduction&lt;/em&gt;. &lt;code&gt;diff_conversations&lt;/code&gt; compares a recording against its own replay and confirms they match. What it does not yet do is assert cross-turn invariants across a &lt;em&gt;changed&lt;/em&gt; agent, run &lt;code&gt;user_confirmed_before&lt;/code&gt; over the replayed conversation and fail when the agent books before the user confirmed. That assertion, a &lt;code&gt;replaygate regress&lt;/code&gt; command, and the OpenTelemetry span wiring I deferred in &lt;a href="https://bessavagner.com/building/replaygate/01-recording-an-agent-before-i-can-replay-it/" rel="noopener noreferrer"&gt;update #1&lt;/a&gt; are the line between a faithful recorder and an actual regression test. It's the next update, and it's the entire point of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Two things worth keeping. The provider-agnostic seam from &lt;a href="https://bessavagner.com/building/replaygate/01-recording-an-agent-before-i-can-replay-it/" rel="noopener noreferrer"&gt;update #1&lt;/a&gt; paid a dividend I didn't have to work for: deterministic replay and a five-provider recorder both fell out of the same one-method protocol, at nearly zero cost. The seam was the whole design, again. And an independent review earns its keep precisely when you treat its findings as claims to test rather than edits to merge: one finding I dug into and rejected would have regressed the very thing it flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The detector: wire the cross-turn invariants into a &lt;code&gt;replaygate regress&lt;/code&gt; command, run them over the replayed conversation, and fail CI when the agent trips one. That's where the deliberately broken agent I planted in &lt;a href="https://bessavagner.com/building/replaygate/01-recording-an-agent-before-i-can-replay-it/" rel="noopener noreferrer"&gt;update #1&lt;/a&gt;, its &lt;code&gt;inject_regression&lt;/code&gt; seed, finally gets caught, and where the deferred OpenTelemetry timing spans get their first consumer.&lt;/p&gt;

&lt;p&gt;ReplayGate is open source at &lt;a href="https://github.com/bessavagner/replaygate" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/bessavagner/replaygate&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>showdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Starting ReplayGate: recording an agent before I can replay it</title>
      <dc:creator>Vagner Bessa</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:56:04 +0000</pubDate>
      <link>https://dev.to/bessavagner/starting-replaygate-recording-an-agent-before-i-can-replay-it-2h3</link>
      <guid>https://dev.to/bessavagner/starting-replaygate-recording-an-agent-before-i-can-replay-it-2h3</guid>
      <description>&lt;p&gt;I'm starting a second project in public, so this is update one of a fresh log. It's &lt;strong&gt;ReplayGate&lt;/strong&gt;: conversation-level regression testing for multi-turn, channel-native AI agents. The problem it chews on is one anyone who has shipped an LLM agent knows, and the regressions that bite hardest aren't in any single reply. The agent books before the user confirmed, three turns back. It re-asks for something it was already told. It forgets a constraint the user set earlier in the session. A prompt tweak that looks harmless trips one of these, and &lt;em&gt;per-turn&lt;/em&gt; assertions sail right past it, because they only ever look at one reply at a time. You can't diff two runs that never produce the same bytes, either.&lt;/p&gt;

&lt;p&gt;ReplayGate's bet is the same one a flight recorder makes: capture the whole conversation once, exactly, on the channel it actually ran on, then replay it as many times as you want and assert the cross-turn properties that matter. This first slice is the &lt;em&gt;record&lt;/em&gt; half plus the foundation everything else hangs on. Replay, the cross-turn divergence detection, and the CI gate are the next plan. I'll get to why I split it that way.&lt;/p&gt;

&lt;p&gt;Here's the whole machine I'm building toward, and, honestly, how little of it exists yet. The key thing to read off it: ReplayGate is the &lt;em&gt;harness&lt;/em&gt; in the box. It doesn't contain your agent, it &lt;strong&gt;brackets&lt;/strong&gt; it (the amber box up top is &lt;em&gt;your&lt;/em&gt; LLM agent under test; I ship a booking-assistant example only so there's something to record against). The green pieces are this update's record half; everything dashed is the next plan.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpb7lcbqzij07fqij6spd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpb7lcbqzij07fqij6spd.png" alt=" " width="763" height="904"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one contract everything depends on
&lt;/h2&gt;

&lt;p&gt;Before any capture code, I wrote the trace contract: a small tree of Pydantic v2 models (&lt;code&gt;Message&lt;/code&gt;, &lt;code&gt;ToolCall&lt;/code&gt;, &lt;code&gt;Turn&lt;/code&gt;, &lt;code&gt;Conversation&lt;/code&gt;) that every other module imports and nothing gets to bypass. A &lt;code&gt;Conversation&lt;/code&gt; is just turns, and it carries the query helpers I'll need when I start asserting things about agent behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;direct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whatsapp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;session_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SessionMeta&lt;/span&gt;
    &lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Turn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agent_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;all_tool_calls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ToolCall&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;turns&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;user_confirmed_before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;user_confirmed_before&lt;/code&gt; is the tell for where this is going. The agent I'm testing is a booking assistant, and the invariant I care about is &lt;em&gt;"never book before the user confirms."&lt;/em&gt; That's a cross-turn property, exactly the kind of thing that's invisible to a single assertion and obvious to a recording you can walk start to finish.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;channel&lt;/code&gt; field on the model is there for the same reason. Agents don't run as tidy Python calls; they run on WhatsApp, on voice, on a webhook, where message ordering, chunking, and a session window quietly expiring mid-flow can break a conversation that passed every unit test. Modeling the channel as part of the trace means the same recording can be replayed as it actually happened, not as an idealized function call. The &lt;code&gt;direct&lt;/code&gt; adapter ships in this slice; WhatsApp is on the roadmap, next to the agents I'd actually want to regression-test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record the model, not the wire
&lt;/h2&gt;

&lt;p&gt;The interesting design decision is &lt;em&gt;where&lt;/em&gt; to capture. The obvious move is to record HTTP (&lt;code&gt;vcrpy&lt;/code&gt; cassettes, fake the server). I deliberately didn't. ReplayGate records at the &lt;strong&gt;application seam&lt;/strong&gt;: it wraps the agent's LLM client and its tool registry, and logs calls there.&lt;/p&gt;

&lt;p&gt;The whole reason that works is a one-method protocol the agent depends on instead of a concrete SDK, the same provider-agnostic &lt;a href="https://bessavagner.com/blog/provider-agnostic-llm-abstraction/" rel="noopener noreferrer"&gt;&lt;code&gt;LLMClient&lt;/code&gt; abstraction&lt;/a&gt; I keep reaching for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LLMClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LLMResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the agent only knows that protocol, I can slot a recorder in front of the real client. In record mode it calls through and logs the exchange under a stable key; in replay mode it answers from the log and never touches the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LLMResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;LLMResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no recorded LLM response for request_key &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;…&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_recording&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is a sha256 over the model, system prompt, messages, and tools, serialized with &lt;code&gt;sort_keys=True&lt;/code&gt; so it's stable across runs. Tools get the same treatment in a &lt;code&gt;ToolRecorder&lt;/code&gt; keyed on &lt;code&gt;(name, args)&lt;/code&gt;. Recording at this layer means the fixture is readable JSON about &lt;em&gt;what the agent actually did&lt;/em&gt;, not opaque HTTP bodies, and the replay matching keys on meaning, not byte order. That's the payoff for not faking the wire.&lt;/p&gt;

&lt;p&gt;This is the same lesson RegWatch's ingestor taught me from the other direction, &lt;a href="https://bessavagner.com/building/regwatch/02-fetching-the-gazette-inlabs-ingestor/" rel="noopener noreferrer"&gt;fake the client, not the server&lt;/a&gt;, and it's why the entire ReplayGate test suite runs with zero network calls. The Anthropic SDK is a dependency; it is never imported in a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  A black box needs somewhere to write
&lt;/h2&gt;

&lt;p&gt;Capture without storage is a stunt, so the other half of this slice is persistence. Each recorded conversation becomes a fixture &lt;em&gt;directory&lt;/em&gt;, not a blob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;conversation.json     # the trace contract, serialized
llm_recording.json    # every LLM exchange, keyed
tool_recording.json   # every tool call + result
spans.jsonl           # OpenTelemetry-aligned timing spans
meta.json             # scenario, agent version, model, recorded_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spans go to a DuckDB store, with attributes aligned to OpenTelemetry's GenAI semantic conventions (&lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.agent.name&lt;/code&gt;, and friends) so the timing data speaks a standard vocabulary instead of one I invented. &lt;code&gt;write&lt;/code&gt; then &lt;code&gt;read&lt;/code&gt; round-trips through real DuckDB in a test, no mock, because a store you can't read back is just a delete with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: I record spans I don't write yet
&lt;/h2&gt;

&lt;p&gt;Here's the deferral I want to name out loud rather than bury. The record orchestrator builds a full fixture (turns, LLM log, tool log, metadata) and sets &lt;code&gt;spans=[]&lt;/code&gt;. The span store is built and tested; the &lt;em&gt;wiring&lt;/em&gt; that emits spans during a record run isn't.&lt;/p&gt;

&lt;p&gt;That's on purpose. Nothing consumes spans until the replay-and-compare work in the next plan, and threading OTel instrumentation through the capture loop before there's a consumer is how you ship a half-feature that drifts out of sync with its only user. So the store lands now with its own tests, and the instrumentation lands next to the thing that reads it. A &lt;code&gt;[TODO]&lt;/code&gt; in code is a smell; a tracked deferral with a reason is a decision. This is the second kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the boring code bit me
&lt;/h2&gt;

&lt;p&gt;I built this strictly test-first: one machine-readable plan, twelve tasks, each a failing test before a line of implementation, in the &lt;a href="https://bessavagner.com/blog/machine-readable-plan-format/" rel="noopener noreferrer"&gt;plan format I've written about before&lt;/a&gt;. Eleven tasks went green without drama. The twelfth, the CLI, did not.&lt;/p&gt;

&lt;p&gt;I'd wired the recorder behind a single Typer command and the test invoked it as a subcommand: &lt;code&gt;record booking_happy ./out&lt;/code&gt;. It blew up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Usage: record [OPTIONS] SCENARIO_NAME OUT_DIR
╭─ Error ─────────────────────────────────────────────╮
│ Got unexpected extra argument(s) (./out)            │
╰─────────────────────────────────────────────────────╯
SystemExit: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause is a Typer sharp edge: when an app has exactly one command, Typer collapses it into a single-command app, so &lt;code&gt;record&lt;/code&gt; is parsed as the &lt;em&gt;first positional argument&lt;/em&gt;, not a subcommand name. &lt;code&gt;record&lt;/code&gt; landed in &lt;code&gt;scenario_name&lt;/code&gt;, &lt;code&gt;booking_happy&lt;/code&gt; in &lt;code&gt;out_dir&lt;/code&gt;, and the real path fell off the end. The fix is one no-op callback that forces Typer back into multi-command mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.callback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;ReplayGate CLI.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mildly maddening, completely undramatic, and exactly the kind of thing a test-first loop surfaces in seconds instead of in a demo. With that in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ replaygate record booking_happy ./fx
recorded booking_happy → ./fx
$ python -m pytest -q
....................                          [100%]
20 passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty tests, ruff clean, fully offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I planted on purpose
&lt;/h2&gt;

&lt;p&gt;The reference booking agent ships with an &lt;code&gt;inject_regression&lt;/code&gt; flag. Flip it on and the agent books an appointment even when the model never signaled a confirmation step, the precise cross-turn failure &lt;code&gt;user_confirmed_before&lt;/code&gt; exists to catch. Right now it's just a seed: the recorder will happily capture both the good run and the broken one. Catching the difference is what the &lt;em&gt;replay&lt;/em&gt; half is for, and wiring that payoff is the whole reason I built the detector's vocabulary into the contract first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Two things worth keeping. First, choosing the capture seam is the entire design: recording at the application layer instead of HTTP is what makes the fixture mean something and the replay deterministic, and it cost nothing because the agent already talked to a protocol, not a vendor. Second, a deferral you can defend in a sentence ("the store has no consumer until the next plan") is a feature of build-in-public; a deferral you can't is just a gap you're hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;replay&lt;/em&gt; half: feed &lt;code&gt;llm_recording&lt;/code&gt;/&lt;code&gt;tool_recording&lt;/code&gt; back in replay mode, run the recorded conversation against the current agent, and diff the two into a &lt;code&gt;ConversationDiff&lt;/code&gt;. That's where &lt;code&gt;inject_regression&lt;/code&gt; finally gets caught, where the OTel spans get wired in, and where a &lt;code&gt;replaygate regress&lt;/code&gt; command and a CI gate turn this from a recorder into an actual regression test.&lt;/p&gt;

&lt;p&gt;ReplayGate is open source from day one, the contract, the record/replay wrappers, and the CLI above live at &lt;a href="https://github.com/bessavagner/replaygate" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/bessavagner/replaygate&lt;/strong&gt;&lt;/a&gt;. The open question I keep circling: for &lt;em&gt;multi-turn&lt;/em&gt; agents, is recording-and-replaying a real conversation the right regression primitive, or have you had better luck with per-turn LLM-judge evals, or with generated user-simulations? If you've ever tried to catch a cross-turn bug, the "it acted before the user confirmed" kind, three turns deep, I'd genuinely like to compare notes before I commit to the replay design.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Scraping a fragile legacy site into a clean time series</title>
      <dc:creator>Vagner Bessa</dc:creator>
      <pubDate>Sat, 27 Jun 2026 16:11:24 +0000</pubDate>
      <link>https://dev.to/bessavagner/scraping-a-fragile-legacy-site-into-a-clean-time-series-3dh8</link>
      <guid>https://dev.to/bessavagner/scraping-a-fragile-legacy-site-into-a-clean-time-series-3dh8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwwnmrfayj77xslbxe7q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwwnmrfayj77xslbxe7q.png" alt=" " width="800" height="390"&gt;&lt;/a&gt;&lt;br&gt;
Some data is only available through a website that was clearly never meant to be read by a program. No API, no bulk download, no CSV export — just a form, a dropdown, a search button, and a table that appears after a spinner. If you want the data as a dataset, you have to drive the page like a human and scrape what comes back.&lt;/p&gt;

&lt;p&gt;That's fine for a handful of rows. It gets interesting when you need &lt;em&gt;years&lt;/em&gt; of them. I wanted the full monthly history of the public Brazilian vehicle price table (FIPE — Fundação Instituto de Pesquisas Econômicas), broken down by brand, model, and year, across every reference month the site still served. That's tens of thousands of lookups against a legacy ASP site that times out, throws modal dialogs, and occasionally just stops responding. A naive loop would die somewhere in hour two and lose everything.&lt;/p&gt;

&lt;p&gt;This post is about how to make that kind of scrape &lt;em&gt;survivable&lt;/em&gt;: how to checkpoint so a crash costs you minutes instead of hours, how to deal with the modal dialogs a legacy site throws at you, how to retry hard without giving up, and how to land the result as clean columnar data you can analyze instead of a pile of half-broken HTML.&lt;/p&gt;
&lt;h2&gt;
  
  
  When the site fights back
&lt;/h2&gt;

&lt;p&gt;The site I was scraping is a classic of the genre. You pick a reference month from a dropdown, type a FIPE code into a text field, the page fires an AJAX request, a second dropdown populates with the matching model-years, you pick one, hit search, and a result table renders. Repeat for the next code. Repeat for the next month.&lt;/p&gt;

&lt;p&gt;Three things make this hostile to automation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's stateful and slow.&lt;/strong&gt; Every step depends on the previous one having finished. The model-year dropdown is empty until the AJAX call for the code you typed comes back, and that call can take a few hundred milliseconds or a few seconds depending on the server's mood.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It throws modal dialogs.&lt;/strong&gt; Ask for a code that doesn't exist in the selected month and you don't get an empty result — you get a modal alert that sits on top of the page and blocks every other interaction until you dismiss it. Miss it, and your next click lands on the overlay instead of the control you wanted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It falls over.&lt;/strong&gt; Run thousands of requests in a row and you'll hit timeouts, refused connections, and the occasional stale element. Not often enough to be useless; often enough that "just let the loop run" is not a plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the design goal is narrow: &lt;strong&gt;finish the job despite the site, and don't lose progress when it breaks.&lt;/strong&gt; Everything below is in service of that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Checkpointing: make the scrape idempotent
&lt;/h2&gt;

&lt;p&gt;The single most important decision is that the scrape must be &lt;strong&gt;resumable&lt;/strong&gt;. If it dies on row 9,000 of 27,000, restarting it should pick up at row 9,001 — not row 1, and not by re-downloading everything to find out where it stopped.&lt;/p&gt;

&lt;p&gt;The trick is to make the &lt;em&gt;output file itself&lt;/em&gt; the checkpoint. Each reference month writes to its own parquet file, and rows are appended in the same order as a stable list of FIPE codes. On startup, for each month, I read whatever is already on disk, look at the last code I successfully wrote, find that code's position in the master list, and resume from the next one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;overwrite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="c1"&gt;# resume from where the last run left off
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pyarrow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;last_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;codigo_fipe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fipe_codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fipe_codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;overwrite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;FileNotFoundError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EmptyDataError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modelo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;marca&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fipe_codes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no separate state file, no database, no "progress.json" to keep in sync with reality. The data &lt;em&gt;is&lt;/em&gt; the progress. If the parquet exists, the last row in it is the high-water mark; if it doesn't, we start from zero. That property — that re-running produces the same result without redoing finished work — is &lt;strong&gt;idempotency&lt;/strong&gt;, and it's the thing that turns a fragile multi-hour job into one you can kill and restart without a second thought.&lt;/p&gt;

&lt;p&gt;A couple of details matter for this to actually be safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order has to be stable.&lt;/strong&gt; The resume logic only works because the list of codes is read from a fixed file (&lt;code&gt;fipe_codes_carros.csv&lt;/code&gt;) in the same order every run. If the iteration order changed between runs, "the last code I wrote" would tell you nothing about what's left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch the empty case.&lt;/strong&gt; A freshly created but empty file raises &lt;code&gt;EmptyDataError&lt;/code&gt;, not &lt;code&gt;FileNotFoundError&lt;/code&gt;. Treating both as "start from zero" avoids a crash-on-resume that would otherwise look like the data being corrupt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Modals, waits, and retries
&lt;/h2&gt;

&lt;p&gt;With resumption in place, the loop only has to survive long enough to make progress between crashes. That comes down to handling the page's three failure modes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dismiss the modal, then continue
&lt;/h3&gt;

&lt;p&gt;When a requested code doesn't exist for the selected month, the site pops a modal alert instead of returning an empty table. The fix is to detect it, read its message, close it, and move on to the next code rather than letting the blocked overlay derail everything that follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;modelos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;select_modelo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ElementClickInterceptedException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# the code may not exist for this reference month
&lt;/span&gt;    &lt;span class="n"&gt;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.modal.alert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;css selector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.content.ps-container&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;css selector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;innerText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;não localizado&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;btnClose&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ElementClickInterceptedException&lt;/code&gt; is the tell: Selenium tried to interact with a control and something — the modal overlay — got in the way. Catching &lt;em&gt;that specific exception&lt;/em&gt; and inspecting the dialog text lets the scraper distinguish "this code legitimately doesn't exist this month" (skip it) from a real failure (let it bubble up).&lt;/p&gt;

&lt;h3&gt;
  
  
  Wait for the page, don't sleep blindly
&lt;/h3&gt;

&lt;p&gt;A legacy AJAX page is the textbook case for &lt;strong&gt;explicit waits&lt;/strong&gt;. The wrong move is a fixed &lt;code&gt;sleep&lt;/code&gt; long enough to cover the worst case — that's slow when the server is fast and still flaky when it's slow. The right move is to poll for the condition you actually care about and proceed the moment it's true. Selenium's &lt;a href="https://www.selenium.dev/documentation/webdriver/waits/" rel="noopener noreferrer"&gt;&lt;code&gt;WebDriverWait&lt;/code&gt;&lt;/a&gt; does exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;selenium.webdriver.support.ui&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebDriverWait&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;selenium.webdriver.support&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;expected_conditions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;EC&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;selenium.webdriver.common.by&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;By&lt;/span&gt;

&lt;span class="c1"&gt;# wait until the result table is actually present, up to 10s
&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WebDriverWait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;EC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presence_of_element_located&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TAG_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An explicit wait is "loops added to the code that poll the application for a specific condition," as the Selenium docs put it — it returns as soon as the table exists, and raises &lt;code&gt;TimeoutException&lt;/code&gt; if it never does. That timeout is a feature: it's the signal that tells the outer loop the page is wedged and the crawler should be restarted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retry hard at the top level
&lt;/h3&gt;

&lt;p&gt;Individual waits handle a slow page. They don't handle the crawler process getting into a bad state — a dropped connection, a browser that stops responding. For that, the whole scrape runs inside a bounded retry loop that catches the transient exceptions, rebuilds the crawler, and tries again, with a finite budget so a permanently-broken site can't spin forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;trials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="n"&gt;keep_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;keep_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;crawler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;thatscraper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Crawler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quit_on_failure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;keep_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_fipe_carros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fipe_codes_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TimeoutException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trials&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Timeout. Restarting crawler...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;trials&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;keep_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ConnectionRefusedError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxRetryError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trials&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connection refused. Restarting crawler...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;trials&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;keep_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this work together with the checkpoint logic. First, a fresh &lt;code&gt;crawler&lt;/code&gt; is built on every iteration, so a wedged browser is thrown away rather than reused. Second — and this is the payoff for all the checkpoint work — when &lt;code&gt;get_fipe_carros&lt;/code&gt; restarts, it reads the parquet files back and resumes from the last code it wrote. A crash on trial 12 doesn't cost the work from trials 1 through 11. The retry budget (&lt;code&gt;trials&lt;/code&gt;) bounds the total damage a permanently-down site can do, so the job fails loudly instead of hanging forever.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-roll the retry bookkeeping, &lt;a href="https://tenacity.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;&lt;code&gt;tenacity&lt;/code&gt;&lt;/a&gt; wraps the same pattern — bounded attempts, exponential backoff, jitter — in a decorator. The principle is identical: retry the transient stuff, cap the attempts, and make sure each retry resumes from a checkpoint rather than starting over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landing a clean time series
&lt;/h2&gt;

&lt;p&gt;Scraping gets you HTML. Analysis needs a table. The bridge is short and worth getting right, because the shape you land the data in determines how painful every later step is.&lt;/p&gt;

&lt;p&gt;Each rendered result table goes straight from its HTML into a DataFrame with &lt;code&gt;pandas.read_html&lt;/code&gt;, which parses an HTML &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; into a list of frames:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;table_element&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;to_table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outerHTML&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_clean_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# strip accents, lowercase, snake_case
&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;search_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_dataframe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then comes the cleaning that turns scraped strings into typed columns. The prices arrive as Brazilian-formatted currency text — &lt;code&gt;R$ 45.046,00&lt;/code&gt;, with a dot for thousands and a comma for decimals — which is a string, not a number, until you fix it. A single regex pass per column handles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# "R$ 45.046,00" -&amp;gt; 45046.00
&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preco_medio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(R\$\s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;inplace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preco_medio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preco_medio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;float&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal of this step is &lt;strong&gt;tidy data&lt;/strong&gt; in Hadley Wickham's sense: each variable a column, each observation a row, one type of observational unit per table. A scraped page is the opposite — values fused into display strings, headers carrying accents and punctuation, types implied rather than stated. Pulling those apart now means every downstream query is a one-liner instead of a parsing exercise.&lt;/p&gt;

&lt;p&gt;Finally, the result lands as &lt;a href="https://parquet.apache.org/docs/" rel="noopener noreferrer"&gt;Apache Parquet&lt;/a&gt; rather than CSV. Parquet is a columnar format: it stores types, compresses well, and reads fast when you only need a couple of columns out of many — which is exactly the access pattern for "average price over time by brand." Writing and reading it from pandas is one call each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pyarrow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pyarrow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;marca&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preco_medio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;columns=&lt;/code&gt; argument is the columnar payoff: reading two columns out of eight touches only those two columns on disk. For a multi-file historical dataset you scan repeatedly while exploring, that adds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data shows
&lt;/h2&gt;

&lt;p&gt;Once every month is a tidy parquet file, the analysis is the easy part. Reading all of them, parsing the prices, and taking the median price per brand per reference month gives a clean monthly time series spanning August 2020 to January 2023 — 30 reference months, assembled from the scraped tables.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fooxiror4reppho805t9x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fooxiror4reppho805t9x.png" alt=" " width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fbessavagner%2Fbessavagner-page%40main%2Fweb%2Fsrc%2Fassets%2Fblog%2Fscraping-a-fragile-legacy-site%2Fprice-trend.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fbessavagner%2Fbessavagner-page%40main%2Fweb%2Fsrc%2Fassets%2Fblog%2Fscraping-a-fragile-legacy-site%2Fprice-trend.svg" alt="Median used-car price for Toyota, VW, BMW and Fiat rose 34 to 47 percent between August 2020 and January 2023, with the steepest climb through 2021." width="447" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The trend is unmistakable and consistent across segments: every brand I tracked rose between roughly 34% and 47% over the window, with the steepest climb through 2021 — the period of the global used-car price surge. I use the &lt;em&gt;median&lt;/em&gt; rather than the mean here on purpose: the raw data has a long right tail (a handful of collector cars priced in the millions of reais) that would drag a mean around month to month. Median per brand per month is the robust summary, and it's a one-line &lt;code&gt;groupby&lt;/code&gt; once the data is tidy.&lt;/p&gt;

&lt;p&gt;It's also worth auditing completeness. A scrape that ran for hours never lands perfectly square — some brand/month cells are richer than others, and a thin month is a hint that the run was interrupted there. A quick heatmap of rows per brand per month makes gaps visible at a glance:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcrqd5j3yfi4gqzrggw2s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcrqd5j3yfi4gqzrggw2s.png" alt=" " width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fbessavagner%2Fbessavagner-page%40main%2Fweb%2Fsrc%2Fassets%2Fblog%2Fscraping-a-fragile-legacy-site%2Fcoverage-heatmap.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fbessavagner%2Fbessavagner-page%40main%2Fweb%2Fsrc%2Fassets%2Fblog%2Fscraping-a-fragile-legacy-site%2Fcoverage-heatmap.svg" alt="Heatmap of rows scraped per brand per reference month, showing roughly even coverage across months with the largest brands contributing the most rows." width="562" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both charts are generated by self-contained scripts that read the scraped parquet files directly; if the files aren't present they fall back to a clearly-labelled synthetic series, so the plots reproduce from a clean checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape keeps coming back
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and this is a pattern you'll meet again any time you pull data from a source that wasn't built to give it to you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the job idempotent.&lt;/strong&gt; Use the output as the checkpoint so a restart resumes instead of redoing. This is the single highest-leverage decision in a long scrape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for conditions, not clocks.&lt;/strong&gt; Explicit waits are faster &lt;em&gt;and&lt;/em&gt; more robust than fixed sleeps, and their timeouts double as your "the page is wedged" signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry the transient, bound the attempts.&lt;/strong&gt; Rebuild the worker on each retry, cap the total, and fail loudly rather than hanging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Land tidy, columnar data.&lt;/strong&gt; Do the string-to-type cleaning once, store it as parquet, and every later analysis is a one-liner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used exactly this approach to assemble a multi-year history of the public FIPE vehicle price table — tens of thousands of rows scraped from a legacy ASP site that timed out constantly — into a set of clean monthly parquet files I could query in seconds. The site fought back the whole way; the checkpointing meant it never actually won.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.selenium.dev/documentation/webdriver/waits/" rel="noopener noreferrer"&gt;Selenium — Waiting Strategies&lt;/a&gt; — official docs on explicit waits (&lt;code&gt;WebDriverWait&lt;/code&gt;, expected conditions) vs. implicit waits&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tenacity.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;tenacity&lt;/a&gt; — general-purpose Python retry library with bounded attempts, exponential backoff, and jitter&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pandas.pydata.org/docs/reference/api/pandas.read_parquet.html" rel="noopener noreferrer"&gt;pandas &lt;code&gt;read_parquet&lt;/code&gt;&lt;/a&gt; — reading Parquet into a DataFrame, including column projection&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pandas.pydata.org/docs/reference/api/pandas.read_html.html" rel="noopener noreferrer"&gt;pandas &lt;code&gt;read_html&lt;/code&gt;&lt;/a&gt; — parsing HTML tables straight into DataFrames&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://parquet.apache.org/docs/" rel="noopener noreferrer"&gt;Apache Parquet documentation&lt;/a&gt; — the columnar storage format and why it reads fast for column-subset queries&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.jstatsoft.org/article/view/v059i10" rel="noopener noreferrer"&gt;Hadley Wickham, "Tidy Data"&lt;/a&gt; — &lt;em&gt;Journal of Statistical Software&lt;/em&gt; 59(10), the canonical definition of tidy data&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://veiculos.fipe.org.br/" rel="noopener noreferrer"&gt;FIPE — Tabela de preços médios de veículos&lt;/a&gt; — the public Brazilian vehicle price table this data comes from&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webscraping</category>
      <category>dataengineering</category>
      <category>pandas</category>
      <category>python</category>
    </item>
    <item>
      <title>Running LLM-Generated Code Without Getting Burned</title>
      <dc:creator>Vagner Bessa</dc:creator>
      <pubDate>Thu, 25 Jun 2026 11:41:57 +0000</pubDate>
      <link>https://dev.to/bessavagner/running-llm-generated-code-without-getting-burned-1gd7</link>
      <guid>https://dev.to/bessavagner/running-llm-generated-code-without-getting-burned-1gd7</guid>
      <description>&lt;p&gt;Language models are good at writing code. Ask one to compute a correlation, reshape a dataset, or plot two columns against each other, and it will happily produce a few lines of Python that do exactly that. What it can't do on its own is &lt;em&gt;run&lt;/em&gt; that code, look at the result, and use it to answer your question. Closing that loop — letting a model write code, execute it, and read the output back — is what turns a chatbot into something that can actually do data analysis.&lt;/p&gt;

&lt;p&gt;It's also where things get dangerous. The moment you execute text a model generated, you're running untrusted code on your machine. This post is about how to do that without handing an attacker (or a confused model) the keys to your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why running model-written code is dangerous
&lt;/h2&gt;

&lt;p&gt;The problem isn't that models are malicious. It's that "run this Python" is an enormous capability, and a model can be steered into misusing it — by a prompt injection hidden in a document it's analyzing, by a jailbreak, or simply by hallucinating something destructive. Once arbitrary code runs in your process, it can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read secrets&lt;/strong&gt; — environment variables, API keys, the contents of nearby files, your database credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach the network&lt;/strong&gt; — exfiltrate data to a remote host, or pull down a second-stage payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exhaust resources&lt;/strong&gt; — an infinite loop or a runaway allocation that takes the host down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape into the host&lt;/strong&gt; — delete files, spawn processes, modify the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the design goal is narrow and specific: &lt;strong&gt;allow general computation while denying general capability.&lt;/strong&gt; You want the model to be able to run &lt;code&gt;numpy&lt;/code&gt; and &lt;code&gt;matplotlib&lt;/code&gt;, but not to open a socket, read &lt;code&gt;/etc/passwd&lt;/code&gt;, or fork-bomb the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The isolation spectrum
&lt;/h2&gt;

&lt;p&gt;There's no single "sandbox" primitive. There's a spectrum, trading strength for cost and complexity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In-process restriction&lt;/strong&gt; (e.g. &lt;a href="https://github.com/zopefoundation/RestrictedPython" rel="noopener noreferrer"&gt;RestrictedPython&lt;/a&gt;) rewrites or limits what Python code can do. It's lightweight but leaky — Python's introspection makes airtight in-process sandboxing notoriously hard. Treat it as a speed bump, not a wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OS-level isolation&lt;/strong&gt; — Linux namespaces, cgroups, and seccomp filters confine a process's view of the filesystem, network, and syscalls. This is what containers are built on, and what Anthropic's &lt;a href="https://github.com/anthropic-experimental/sandbox-runtime" rel="noopener noreferrer"&gt;sandbox-runtime&lt;/a&gt; applies without a full container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Containers&lt;/strong&gt; (Docker, Podman) bundle that isolation into a disposable unit with its own filesystem and resource limits. The pragmatic default for most teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MicroVMs&lt;/strong&gt; (&lt;a href="https://firecracker-microvm.github.io/" rel="noopener noreferrer"&gt;Firecracker&lt;/a&gt;) and &lt;strong&gt;gVisor&lt;/strong&gt; (&lt;a href="https://gvisor.dev/" rel="noopener noreferrer"&gt;gvisor.dev&lt;/a&gt;) add a hardware-virtualization or kernel-emulation boundary that a plain container can't offer — the standard choice when you're running &lt;em&gt;other people's&lt;/em&gt; untrusted code at scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebAssembly&lt;/strong&gt; (&lt;a href="https://pyodide.org/" rel="noopener noreferrer"&gt;Pyodide&lt;/a&gt;) runs Python compiled to WASM with no host filesystem or network by default — strong isolation, at the cost of a constrained runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most applications, a &lt;strong&gt;disposable container&lt;/strong&gt; hits the sweet spot: strong enough, cheap enough, and easy to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal Docker sandbox
&lt;/h2&gt;

&lt;p&gt;You don't need a framework to get started. Here's the shape of a locked-down container using the Docker SDK for Python — every flag here is doing security work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;docker&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_env&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_untrusted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;# the model's snippet
&lt;/span&gt;        &lt;span class="n"&gt;network_disabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# no network egress at all
&lt;/span&gt;        &lt;span class="n"&gt;mem_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;# cap memory
&lt;/span&gt;        &lt;span class="n"&gt;pids_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;# cap process count (anti fork-bomb)
&lt;/span&gt;        &lt;span class="n"&gt;read_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;# read-only root filesystem
&lt;/span&gt;        &lt;span class="n"&gt;cap_drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                 &lt;span class="c1"&gt;# drop every Linux capability
&lt;/span&gt;        &lt;span class="n"&gt;remove&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;# discard the container afterwards
&lt;/span&gt;        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container is created for one snippet and thrown away. It has no network, a hard memory ceiling, a capped process count, a read-only root, and no Linux capabilities. If the model writes something hostile, the blast radius is a throwaway container with nowhere to go and nothing to steal.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-roll this, &lt;a href="https://github.com/vndee/llm-sandbox" rel="noopener noreferrer"&gt;&lt;code&gt;llm-sandbox&lt;/code&gt;&lt;/a&gt; wraps the same idea in a small API, with Docker, Podman, and Kubernetes backends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;llm_sandbox&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SandboxSession&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;SandboxSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import numpy as np; print(np.mean([1, 2, 3]))&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;libraries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# "2.0"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Capturing results, including plots
&lt;/h2&gt;

&lt;p&gt;Running code is only half of it — you need the output back in a form the model and the user can use. &lt;code&gt;stdout&lt;/code&gt; and &lt;code&gt;stderr&lt;/code&gt; are easy. Plots are the interesting part: a data agent that can't show a chart isn't much of a data agent.&lt;/p&gt;

&lt;p&gt;The trick is to run the snippet inside a session that captures matplotlib figures and hands them back as images. &lt;code&gt;llm-sandbox&lt;/code&gt; does this when plotting is enabled — the model writes ordinary plotting code and calls &lt;code&gt;plt.show()&lt;/code&gt;, and the infrastructure turns the figure into a base64-encoded PNG you can stream into the chat. Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# inside the sandbox session, with artifact capture turned on
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import matplotlib.pyplot as plt&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plt.plot([1, 2, 3], [2, 4, 6]); plt.show()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# captured figures
&lt;/span&gt;    &lt;span class="nf"&gt;save_png&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_base64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model never has to know about your capture mechanism. It writes normal code; the harness handles turning a figure into a displayable artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defense in depth
&lt;/h2&gt;

&lt;p&gt;The container is the hard boundary, but it shouldn't be the only one. A small amount of belt-and-suspenders pays off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Constrain the model with the prompt.&lt;/strong&gt; Tell it which libraries are allowed and what's off-limits. This keeps it inside the lines &lt;em&gt;before&lt;/em&gt; the container would have to stop it:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  You may use only: pandas, numpy, matplotlib, seaborn, scipy.
  Never import os, subprocess, socket, or requests.
  Do not read or write files outside the working directory,
  and never make network calls.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist libraries&lt;/strong&gt;, don't blocklist them. Decide what can be installed; reject everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap code length and execution time.&lt;/strong&gt; A wall-clock timeout on each run prevents a clever or accidental hang. (Anthropic's hosted code-execution tool, for instance, enforces a per-cell time limit and returns a timeout result rather than blocking — see the &lt;a href="https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/code-execution-tool" rel="noopener noreferrer"&gt;code execution tool docs&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degrade gracefully.&lt;/strong&gt; If execution isn't available — the feature is off, or there's no Docker socket — return a friendly error instead of crashing the agent. The model should be able to keep answering questions even when it can't run code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these replace the sandbox. They reduce how often it has to do its job, and they shrink the surface the model can probe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't want to run your own? Use a managed sandbox
&lt;/h2&gt;

&lt;p&gt;Running disposable containers in production — pooling, scaling, cleaning up — is real work. Several services exist specifically to take it off your hands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://e2b.dev/" rel="noopener noreferrer"&gt;E2B&lt;/a&gt;&lt;/strong&gt; runs AI-generated code in Firecracker microVMs with a code-interpreter SDK; sandboxes start in well under a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://modal.com/" rel="noopener noreferrer"&gt;Modal&lt;/a&gt;&lt;/strong&gt; offers serverless sandboxes you can spin up per request.&lt;/li&gt;
&lt;li&gt;Model providers ship their own server-side execution. &lt;strong&gt;Anthropic's &lt;a href="https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/code-execution-tool" rel="noopener noreferrer"&gt;code execution tool&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;OpenAI's &lt;a href="https://platform.openai.com/docs/assistants/tools/code-interpreter" rel="noopener noreferrer"&gt;Code Interpreter&lt;/a&gt;&lt;/strong&gt; both run the model's code in a hosted sandbox and return results and files — no infrastructure on your side at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A nice property of building behind a small &lt;code&gt;execute(code)&lt;/code&gt; interface is that the backend becomes a swappable detail: run a local container in development, delegate to a provider's sandbox in production, and the agent code barely changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it fast: a warm pool
&lt;/h2&gt;

&lt;p&gt;One practical wrinkle: cold-starting a container with &lt;code&gt;pandas&lt;/code&gt;, &lt;code&gt;numpy&lt;/code&gt;, &lt;code&gt;matplotlib&lt;/code&gt;, and friends adds seconds of latency to every request. The fix is a &lt;strong&gt;pre-warmed pool&lt;/strong&gt; — create a handful of ready containers at startup, hand one out per request, and reclaim idle ones when traffic drops. You trade a little idle memory for a much snappier interaction, which matters a lot when a user is waiting on a chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth getting right
&lt;/h2&gt;

&lt;p&gt;This is a recurring shape in modern AI engineering: give a model real computational power without giving it dangerous reach. The durable answers are the same ones that show up across every implementation — disposable containers for isolation, a warm pool for latency, an allowlist plus prompt guardrails for defense in depth, and a backend abstraction so you can run locally or in the cloud without rewriting the agent.&lt;/p&gt;

&lt;p&gt;I used exactly this approach to add a code-execution data agent to an internal analytics application, letting it answer open-ended questions with custom charts while staying safely contained. The specifics differ from project to project, but the principles travel — and they're worth internalizing before you wire a language model up to a Python interpreter.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/vndee/llm-sandbox" rel="noopener noreferrer"&gt;llm-sandbox&lt;/a&gt; — lightweight Python sandbox runtime (Docker / Podman / Kubernetes) · &lt;a href="https://vndee.github.io/llm-sandbox/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://e2b.dev/" rel="noopener noreferrer"&gt;E2B&lt;/a&gt; — Firecracker-backed sandboxes for AI agents · &lt;a href="https://e2b.dev/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://firecracker-microvm.github.io/" rel="noopener noreferrer"&gt;Firecracker&lt;/a&gt; — lightweight microVMs (the AWS Lambda / E2B substrate)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://gvisor.dev/" rel="noopener noreferrer"&gt;gVisor&lt;/a&gt; — a user-space kernel for stronger container isolation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pyodide.org/" rel="noopener noreferrer"&gt;Pyodide&lt;/a&gt; — CPython compiled to WebAssembly&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/zopefoundation/RestrictedPython" rel="noopener noreferrer"&gt;RestrictedPython&lt;/a&gt; — in-process Python restriction (a speed bump, not a wall)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropic-experimental/sandbox-runtime" rel="noopener noreferrer"&gt;Anthropic sandbox-runtime&lt;/a&gt; — OS-level filesystem/network restriction without a container&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/code-execution-tool" rel="noopener noreferrer"&gt;Anthropic code execution tool&lt;/a&gt; and &lt;a href="https://platform.openai.com/docs/assistants/tools/code-interpreter" rel="noopener noreferrer"&gt;OpenAI Code Interpreter&lt;/a&gt; — hosted, provider-run sandboxes&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>security</category>
    </item>
  </channel>
</rss>
