{
  "source_file": "0930",
  "source_platform": "Dev.to",
  "author": "Tae Kim",
  "title_listed": "The Retry Counter Said 7, The Config Said 3",
  "keywords": "",
  "domain": "大模型算法",
  "content_type": "深度技术解析",
  "is_paid_flag": "否",
  "url": "https://dev.to/hannune/the-retry-counter-said-7-the-config-said-3-383l",
  "publish_date": "2026-08-11",
  "corpus_stage": "",
  "notes": "",
  "id": "6d12786828f3",
  "collected_at": "2026-10-02T14:03:56",
  "title": "The Retry Counter Said 7, The Config Said 3 - DEV Community",
  "body_markdown": "A LangGraph pipeline I was reviewing last year. Token costs were running over projection, slow runs were taking about 3x the normal time. Nothing was throwing errors.\nWhen I added a retry counter per step and ran a few slow cases through, the resolve step showed a count of 7. The config had\nmax_retries=3\n.\nThe discrepancy came from two retry layers that had been written independently and never asked to coordinate. The step-level nodes had retry logic. The orchestrator graph had a recovery edge that re-invoked subgraphs when steps returned an error state — and that edge was triggering twice on ambiguous failure modes before the orchestrator gave up. So the actual retry count for those steps was whatever the step-level config said, plus two orchestrator re-invocations.\nOnce I had the counter, I could see it. Before that, I was looking at prompt quality and model selection because that's what the symptoms looked like.\nThe instrumentation was pretty simple: a counter object passed to every node, checked before retrying, incremented after.\nimport\ntime\nfrom\ncollections\nimport\ndefaultdict\nclass\nRetryBudget\n:\ndef\n__init__\n(\nself\n,\nrun_limit\n=\n12\n,\nstep_limit\n=\n3\n):\nself\n.\nrun_limit\n=\nrun_limit\nself\n.\nstep_limit\n=\nstep_limit\nself\n.\n_total\n=\n0\nself\n.\n_by_step\n=\ndefaultdict\n(\nint\n)\ndef\ncan_retry\n(\nself\n,\nstep\n:\nstr\n)\n->\nbool\n:\nif\nself\n.\n_total\n>=\nself\n.\nrun_limit\n:\nreturn\nFalse\nreturn\nself\n.\n_by_step\n[\nstep\n]\n<\nself\n.\nstep_limit\ndef\ncharge\n(\nself\n,\nstep\n:\nstr\n)\n->\nNone\n:\nself\n.\n_total\n+=\n1\nself\n.\n_by_step\n[\nstep\n]\n+=\n1\ndef\nstate\n(\nself\n)\n->\ndict\n:\nreturn\n{\n\"\ntotal\n\"\n:\nself\n.\n_total\n,\n\"\nby_step\n\"\n:\ndict\n(\nself\n.\n_by_step\n)}\nEnter fullscreen mode\nExit fullscreen mode\nrun_limit=12\nis meant to account for two layers interacting. If each step allows 3 retries and you have two layers that can both fire, you need the ceiling to be higher than 3 or the orchestrator layer will consume the whole budget on its first re-invoke. The team was using 12 which was basically 3 steps times 4 (step + orchestrator with some slack). It's not a principled number.\nThe orchestrator's recovery edge went through the same budget object. That was the main structural change — before, the orchestrator had no visibility into how many retries the step level had already used, and the step level had no idea the orchestrator was going to re-invoke. Sharing one budget object between both makes the interaction visible.\nAfter a week of production data,\n_by_step\nshowed that the resolve step was consuming most of the budget on slow runs. Looking at those specific calls, the upstream API was returning 200 with malformed JSON on a particular input pattern, and the retries were doing nothing because the response was consistently broken for that input. We ended up fixing input normalization before the call, not the retry config. But we wouldn't have known which step to look at without seeing where the retries were going.\nThe unresolved part is what to do when the run-level budget hits zero mid-run.\nThis pipeline accumulates meaningful intermediate state by the time it gets to the downstream steps. Early steps do entity resolution, later ones do candidate ranking, and by step 6 or 7 there's significant useful work already done. Failing the whole run discards that. Returning whatever completed without noting that the run didn't finish is wrong because the consumer runs aggregations on top of multiple runs, and incomplete results fed into aggregation without flagging produce incorrect totals.\nWhat we ended up shipping was a result object with a\ncomplete: bool\nfield and a list of which steps didn't finish. The consumer is supposed to check it before using the data. In practice, the check is inconsistent. The\ncomplete: False\nflag gets read in some call sites and ignored in others. The next thing we're considering is making the result type force the consumer to handle the incomplete case explicitly rather than just exposing a bool that can be ignored. Either a tagged union or a checked exception type. We haven't gotten there yet and I'm not certain it would actually fix the consistency problem even if we did.\nThere's probably a version where this becomes a dead letter queue and a human reviews the incomplete runs. We haven't needed that yet.",
  "metadata": {
    "title": "The Retry Counter Said 7, The Config Said 3 - DEV Community",
    "description": "A LangGraph pipeline I was reviewing last year. Token costs were running over projection, slow runs... Tagged with aiagents, python, llm, architecture.",
    "keywords": "aiagents, python, llm, architecture, software, coding, development, engineering, inclusive, community",
    "author": null,
    "og:type": "article",
    "og:url": "https://dev.to/hannune/the-retry-counter-said-7-the-config-said-3-383l",
    "og:title": "The Retry Counter Said 7, The Config Said 3",
    "og:description": "A LangGraph pipeline I was reviewing last year. Token costs were running over projection, slow runs...",
    "og:site_name": "DEV Community",
    "og:image": "https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1714901423336-1884cd3fb50f%3Fw%3D1200%26h%3D630%26fit%3Dcrop",
    "twitter:site": "@thepracticaldev",
    "twitter:creator": "@HannuneTech",
    "twitter:title": "The Retry Counter Said 7, The Config Said 3",
    "twitter:description": "A LangGraph pipeline I was reviewing last year. Token costs were running over projection, slow runs...",
    "twitter:card": "summary_large_image",
    "twitter:widgets:new-embed-design": "on",
    "twitter:image:src": "https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1714901423336-1884cd3fb50f%3Fw%3D1200%26h%3D630%26fit%3Dcrop"
  },
  "paywall": {
    "listed_paid": false,
    "detected": false,
    "marker": "",
    "public_only": false,
    "policy": "付费/会员内容只保留页面公开可见部分，不绕过付费墙"
  },
  "crawl": {
    "success": true,
    "status_code": 200,
    "error": ""
  },
  "code_blocks": [
    {
      "file": "attachments/6d12786828f3/code/body-000.txt",
      "chars": 622,
      "language": "text"
    }
  ],
  "images": [],
  "audio": [],
  "videos": [],
  "links": [],
  "repair_version": 2,
  "repaired_at": "2026-10-14T10:49:39Z",
  "source_url": "https://dev.to/hannune/the-retry-counter-said-7-the-config-said-3-383l",
  "papers": [],
  "actual_video_files": [],
  "body_chars": 4259,
  "expected_body_images": 0,
  "discovery_status": "ok",
  "delivery_status": "ok",
  "missing_reason": "",
  "raw_html": "attachments/6d12786828f3/pages/source.html",
  "local_html": "attachments/6d12786828f3/pages/article.html",
  "body_base_path": "attachments/6d12786828f3/pages/",
  "body_markdown_path": "attachments/6d12786828f3/pages/article.md"
}