From c6e075d5dc8c306ee9fdf2717060748143030b91 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 3 Oct 2026 16:28:45 -0300 Subject: [PATCH 01/11] =?UTF-8?q?Resposta=20recusada=20com=20n=C3=BAmero?= =?UTF-8?q?=20no=20texto=20n=C3=A3o=20vira=20erro=20de=20credencial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No claude_code, a falha de parsing ou de validação da resposta saía como ValidationError ou ValueError com o texto da resposta na mensagem, e a classificação procurava 401, 403 e 404 nesse texto. Com "art. 401 da CLT" na resposta, a linha falhava sem nova tentativa e a mensagem mandava reconfigurar a autenticação. Agora a falha levanta ProviderRejectedOutputError, transitória pela classe. No codex, a mensagem amigável do ProviderOutputError passa a ser decidida pela classe, e a mensagem do erro de schema deixa de trazer o valor recusado. validation_error_summary, em errors.py, monta o caminho e a regra de cada erro sem o valor, para o LangChain, o claude_code e o codex. Closes #192 --- CHANGELOG.md | 1 + docs/en/guides/error-handling.md | 2 ++ docs/en/reference/exceptions.md | 4 +-- docs/guides/error-handling.md | 2 ++ docs/reference/exceptions.md | 4 +-- src/dataframeit/claude_code.py | 19 +++++++++++-- src/dataframeit/codex.py | 3 +- src/dataframeit/errors.py | 37 +++++++++++++++++++++--- src/dataframeit/llm.py | 8 ++---- tests/test_claude_code.py | 49 ++++++++++++++++++++++++++++++++ tests/test_codex.py | 21 ++++++++++++++ 11 files changed, 132 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0257bcd..d2e6c85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado (#192). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). - A forma de `condition` em `json_schema_extra` é conferida antes de processar, com `ValueError` que diz o que é aceito: dict com `field` em texto e um operador (`equals`, `not_equals`, `in`, `not_in` ou `exists`), ou callable. Antes, `condition={}` e os demais valores falsos, como `False` ou `[]`, rodavam o campo sem aviso, embora `evaluate_condition` trate o dict vazio como condição falsa; `field` que não era texto levantava `AttributeError`; e as demais formas eram avisadas a cada linha, com o campo pulado (#163). diff --git a/docs/en/guides/error-handling.md b/docs/en/guides/error-handling.md index d4715922..4c84fa29 100644 --- a/docs/en/guides/error-handling.md +++ b/docs/en/guides/error-handling.md @@ -75,6 +75,8 @@ The wait before attempt `n + 1` is `min(base_delay × 2^(n-1), max_delay)`, plus With LangChain providers, the next attempt sends the model the rejected response and the list of errors, each with the field path and the rejected value, asking it to answer again fixing those points. Repeating the same prompt tends to repeat the same error. When the response is not JSON, the request carries the parser's error text. +With `claude_code`, the next attempt repeats the request, without the list of errors. With `codex`, a response that fails validation raises `ProviderOutputError`, and the row fails without a new attempt. In both, `_error_details` names the path and the rule of each error, without the rejected value. + With OpenAI, the SDK validates the response inside the call and raises before returning the message; the rejected response and its tokens are read from the HTTP response attached to the error. When no raw response is available, the correction request goes with the prompt, carrying the rejected values. When a later attempt succeeds, tokens from the rejected ones are added to `_input_tokens` and `_output_tokens`, because they are billed too; if every attempt fails, the row gets status `error`, no token count, and `_error_details` names the field and the rule of each error. ### Permanent Errors (no retry) diff --git a/docs/en/reference/exceptions.md b/docs/en/reference/exceptions.md index ed68bc95..8ecdaaec 100644 --- a/docs/en/reference/exceptions.md +++ b/docs/en/reference/exceptions.md @@ -50,7 +50,7 @@ ValueError | `ProviderError` | No | Definitive provider error, such as rejected authentication or exhausted `claude_code` budget | | `ProviderTransientError` | Yes | Network or service failure that usually goes away on its own | | `ProviderOverloadedError` | Yes | Provider overload or HTTP 429 | -| `ProviderRejectedOutputError` | Yes | The response failed validation against the Pydantic model; the new attempt sends the model the rejected response and the error | -| `ProviderOutputError` | No | The provider finished without a usable response, such as a `codex` turn with no content | +| `ProviderRejectedOutputError` | Yes | The response failed validation against the Pydantic model. With LangChain providers, the new attempt sends the model the rejected response and the error; with `claude_code`, it repeats the request | +| `ProviderOutputError` | No | The provider finished without a usable response, such as a `codex` turn with no content or with a response that fails validation against the Pydantic model | Errors from providers via LangChain do not use these classes. They are classified by the type LangChain itself declares, by the HTTP status and by the message. The details are in [Error Handling](../guides/error-handling.md). diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index d3f39096..b35627c6 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -75,6 +75,8 @@ A espera antes da tentativa `n + 1` é `min(base_delay × 2^(n-1), max_delay)`, Nos providers do LangChain, a tentativa seguinte leva ao modelo a resposta recusada e a lista de erros, cada um com o caminho do campo e o valor recusado, e pede que ele responda de novo corrigindo esses pontos. Repetir o mesmo prompt tende a repetir o mesmo erro. Quando a resposta não é JSON, o pedido leva o texto do erro do parser. +No `claude_code`, a tentativa seguinte repete o pedido, sem a lista de erros. No `codex`, a resposta que não passa na validação levanta `ProviderOutputError`, e a linha falha sem nova tentativa. Nos dois, `_error_details` diz o caminho e a regra de cada erro, sem o valor recusado. + Na OpenAI, o SDK valida a resposta dentro da chamada e levanta o erro antes de devolver a mensagem; a resposta recusada e os tokens são lidos da resposta HTTP anexada ao erro. Quando nenhuma resposta bruta está disponível, o pedido de correção vai junto do prompt, com os valores recusados. Quando uma tentativa seguinte dá certo, os tokens das recusadas entram em `_input_tokens` e `_output_tokens`, porque também são cobrados; se todas falham, a linha fica com status `error`, sem contagem de tokens, e `_error_details` diz o campo e a regra de cada erro. ### Erros Permanentes (sem retry) diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md index b49db5a6..713193eb 100644 --- a/docs/reference/exceptions.md +++ b/docs/reference/exceptions.md @@ -50,7 +50,7 @@ ValueError | `ProviderError` | Não | Erro definitivo do provider, como autenticação recusada ou orçamento do `claude_code` esgotado | | `ProviderTransientError` | Sim | Falha de rede ou do serviço que costuma passar sozinha | | `ProviderOverloadedError` | Sim | Sobrecarga ou HTTP 429 do provider | -| `ProviderRejectedOutputError` | Sim | A resposta não passou na validação do modelo Pydantic; a nova tentativa leva ao modelo a resposta recusada e o erro | -| `ProviderOutputError` | Não | O provider terminou sem resposta utilizável, como um turno do `codex` sem conteúdo | +| `ProviderRejectedOutputError` | Sim | A resposta não passou na validação do modelo Pydantic. Nos providers do LangChain, a nova tentativa leva ao modelo a resposta recusada e o erro; no `claude_code`, repete o pedido | +| `ProviderOutputError` | Não | O provider terminou sem resposta utilizável, como um turno do `codex` sem conteúdo ou com resposta que não passa na validação do modelo Pydantic | Erros dos providers via LangChain não usam essas classes. Eles são classificados pelo tipo que a própria LangChain declara, pelo status HTTP e pela mensagem. Os detalhes estão em [Tratamento de Erros](../guides/error-handling.md). diff --git a/src/dataframeit/claude_code.py b/src/dataframeit/claude_code.py index d7a854c3..8a43ff93 100644 --- a/src/dataframeit/claude_code.py +++ b/src/dataframeit/claude_code.py @@ -11,11 +11,15 @@ import json from typing import TYPE_CHECKING, Any, TypeVar +from pydantic import ValidationError + from .errors import ( ProviderError, ProviderOverloadedError, + ProviderRejectedOutputError, ProviderTransientError, retry_with_backoff, + validation_error_summary, ) from .llm import LLMConfig, _parse_usage_metadata, build_prompt from .utils import parse_json @@ -234,9 +238,18 @@ def _call() -> dict: msg = "Claude Code SDK retornou resposta vazia" raise ValueError(msg) - # Parse e validação - parsed = parse_json(response_text) - validated = pydantic_model.model_validate(parsed) + # A recusa sai como ProviderRejectedOutputError, transitória pela classe e + # sem o texto da resposta na mensagem (ver validation_error_summary). + try: + parsed = parse_json(response_text) + except ValueError as err: + msg = "Falha no parsing da resposta: o texto não contém um JSON válido" + raise ProviderRejectedOutputError(msg) from err + try: + validated = pydantic_model.model_validate(parsed) + except ValidationError as err: + msg = f"Falha na validação da resposta: {validation_error_summary(err)}" + raise ProviderRejectedOutputError(msg) from err usage = _usage_from_sdk(getattr(result, "usage", None), spent) return {"data": validated.model_dump(), "usage": usage} diff --git a/src/dataframeit/codex.py b/src/dataframeit/codex.py index b0e13a29..e95dab6a 100644 --- a/src/dataframeit/codex.py +++ b/src/dataframeit/codex.py @@ -29,6 +29,7 @@ ProviderTransientError, ProviderUsageLimitError, retry_with_backoff, + validation_error_summary, ) from .llm import LLMConfig, build_prompt @@ -697,7 +698,7 @@ def _invoke_once(self, prompt: str, usage_total: dict[str, int]) -> dict: try: validated = self._pydantic_model.model_validate_json(final_response) except ValidationError as err: - msg = f"Resposta do Codex não corresponde ao schema: {err}" + msg = f"Resposta do Codex não corresponde ao schema: {validation_error_summary(err)}" raise ProviderOutputError(msg) from err return {"data": validated.model_dump(), "usage": dict(usage_total) or None} diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index d7b00574..d6f050c3 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -22,6 +22,8 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable + from pydantic import ValidationError + from .search import SearchProvider @@ -81,6 +83,25 @@ class ProviderOutputError(ValueError): """Resposta definitiva incompatível com o contrato de saída.""" +# Teto de regras listadas na mensagem de uma resposta recusada. +_MAX_REJECTED_RULES = 20 + + +def validation_error_summary(error: ValidationError) -> str: + """Caminho e regra de cada erro de validação, sem o valor recusado. + + A mensagem vai para `_error_details` e passa pela classificação de erros. O + valor recusado vem do texto analisado: levaria dado da linha para a coluna, e + um número como '401' nele seria tomado por status HTTP. + """ + rules = [ + f"{'.'.join(str(part) for part in item.get('loc', ())) or '(resposta inteira)'}: " + f"{item.get('msg', '')}" + for item in error.errors()[:_MAX_REJECTED_RULES] + ] + return f"{error.error_count()} erro(s): {'; '.join(rules)}" + + # Erros considerados recuperáveis (transientes) RECOVERABLE_ERRORS = ( # Timeouts e deadlines @@ -403,14 +424,22 @@ def get_friendly_error_message( # noqa: C901, PLR0911 (uma saída por categoria Returns: Mensagem de erro amigável com instruções de como resolver. """ + # As duas classes de resposta recusada decidem pela classe: a mensagem não + # traz o texto analisado, mas um número como '401' no caminho de um campo + # não pode virar erro de chave. if isinstance(error, ProviderRejectedOutputError): - # Decidida pela classe: a mensagem não traz o texto analisado, mas um - # número como '401' no caminho de um campo não pode virar erro de chave. return ( "RESPOSTA RECUSADA PELA VALIDAÇÃO DO MODELO PYDANTIC\n" f"{error}\n" - "As tentativas levaram o erro de volta ao modelo, sem sucesso. Veja as regras " - "do modelo que falharam; se o texto é ambíguo, a instrução do campo pode ajudar." + "Nenhuma tentativa produziu resposta válida. Veja as regras do modelo que " + "falharam; se o texto é ambíguo, a instrução do campo pode ajudar." + ) + if isinstance(error, ProviderOutputError): + return ( + "RESPOSTA FORA DO CONTRATO DE SAÍDA\n" + f"{error}\n" + "O provider respondeu, mas a resposta não serve ao modelo Pydantic, e a linha " + "não é tentada de novo. Veja as regras do modelo que falharam." ) error_str = f"{type(error).__name__}: {error}".lower() diff --git a/src/dataframeit/llm.py b/src/dataframeit/llm.py index 8e44993e..9d838393 100644 --- a/src/dataframeit/llm.py +++ b/src/dataframeit/llm.py @@ -10,7 +10,7 @@ from langchain_core.exceptions import OutputParserException from pydantic import BaseModel, ValidationError -from .errors import ProviderRejectedOutputError, retry_with_backoff +from .errors import ProviderRejectedOutputError, retry_with_backoff, validation_error_summary if TYPE_CHECKING: from collections.abc import Callable @@ -423,11 +423,7 @@ def _request_correction( validation_error = _validation_error_of(error, pydantic_model, payload) if validation_error is not None: detail = _format_validation_error(validation_error) - rules = [ - f"{'.'.join(str(part) for part in item.get('loc', ())) or '(resposta inteira)'}: {item.get('msg', '')}" - for item in validation_error.errors()[:_MAX_ERRORS] - ] - summary = f"{validation_error.error_count()} erro(s): {'; '.join(rules)}" + summary = validation_error_summary(validation_error) elif error is not None: detail = str(error)[:_MAX_ERROR_TEXT] summary = f"resposta fora do esquema ({type(error).__name__})" diff --git a/tests/test_claude_code.py b/tests/test_claude_code.py index 2ede3803..dddc7f69 100644 --- a/tests/test_claude_code.py +++ b/tests/test_claude_code.py @@ -19,7 +19,9 @@ from dataframeit.errors import ( ProviderError, ProviderOverloadedError, + ProviderRejectedOutputError, ProviderTransientError, + get_friendly_error_message, is_rate_limit_error, is_recoverable_error, validate_provider_dependencies, @@ -563,3 +565,50 @@ def test_resposta_so_com_espacos_e_erro(sdk_falso): with pytest.raises(ValueError, match="Claude Code SDK retornou resposta vazia"): _chamar() + + +# Números como '401' ou '403' aparecem em texto jurídico ("art. 401 da CLT", +# "Súmula 403"); a resposta recusada não pode virar erro de credencial. +@pytest.mark.parametrize( + "resposta", + [ + '{"sentimento": "positivo", "confianca": "art. 401 da CLT"}', + '{"sentimento": "Súmula 403 do STJ", "confianca": 0.9', + ], +) +def test_resposta_recusada_com_numero_no_texto_e_tentada_de_novo(sdk_falso, resposta): + sdk_falso["mensagens_do_sdk"] = [ + _AssistantMessageFalso([_TextBlockFalso(resposta)]), + _ResultMessageFalso(usage=None), + ] + + with ( + pytest.warns(UserWarning, match=r"Tentativa 1/2 falhou \(ProviderRejectedOutputError\)"), + pytest.raises(ProviderRejectedOutputError) as erro, + ): + _chamar(replace(_config_claude_code(), max_retries=2)) + + assert len(sdk_falso["opcoes"]) == 2 + mensagem = str(erro.value) + assert "401" not in mensagem + assert "403" not in mensagem + amigavel = get_friendly_error_message(erro.value, "claude_code") + assert "AUTENTICAÇÃO" not in amigavel + assert "PERMISSÃO" not in amigavel + + +def test_resposta_recusada_leva_caminho_e_regra_sem_o_valor(sdk_falso): + sdk_falso["mensagens_do_sdk"] = [ + _AssistantMessageFalso( + [_TextBlockFalso('{"sentimento": "positivo", "confianca": "valor secreto"}')] + ), + _ResultMessageFalso(usage=None), + ] + + with pytest.raises(ProviderRejectedOutputError) as erro: + _chamar() + + assert str(erro.value) == ( + "Falha na validação da resposta: 1 erro(s): confianca: " + "Input should be a valid number, unable to parse string as a number" + ) diff --git a/tests/test_codex.py b/tests/test_codex.py index a9b0b395..2dfbf256 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -1244,6 +1244,27 @@ def test_final_response_is_validated_directly_by_pydantic_json( assert client.thread_start.call_count == 1 + def test_number_in_rejected_response_is_not_a_credential_error(self, codex_sdk, tmp_path): + response = '{"sentimento": "Súmula 403 do STJ", "confianca": "art. 401"}' + backend, _, _, _ = initialized_backend( + tmp_path, codex_sdk, make_result(codex_sdk, response=response) + ) + + with ( + pytest.warns(UserWarning, match="não-recuperável"), + pytest.raises(ProviderOutputError) as error, + ): + backend.invoke("texto") + + message = str(error.value) + assert "Súmula" not in message + assert "art. 401" not in message + assert "confianca: Input should be a valid number" in message + friendly = get_friendly_error_message(error.value, "codex") + assert "AUTENTICAÇÃO" not in friendly + assert "PERMISSÃO" not in friendly + assert "login" not in friendly + @pytest.mark.parametrize( ("response", "status", "message"), [ From c19757869f012e9c80a93c45bd97cd65e29c92d5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 3 Oct 2026 16:39:32 -0300 Subject: [PATCH 02/11] Rate limit e mensagens decididos pela classe na resposta recusada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_rate_limit_error passa a decidir pela classe para ProviderError, ProviderConfigurationError e ProviderOutputError. Um '429' no caminho de um erro de validação, como a chave de um dict na resposta do codex, contava como rate limit e reduzia os workers. A mensagem amigável do ProviderOutputError descreve também a resposta vazia e o turno interrompido, em que nenhuma regra falhou. A docstring de validation_error_summary, o guia de erros e o CHANGELOG dizem que caminho e regra ainda trazem texto da resposta quando o Pydantic o põe ali. O teto de erros listados vira uma constante só, usada pelo resumo e pelo pedido de correção do LangChain. Testes novos cobrem o ramo do ProviderOutputError na mensagem amigável com número no caminho e o rate limit decidido pela classe. --- CHANGELOG.md | 2 +- docs/en/guides/error-handling.md | 4 ++-- docs/guides/error-handling.md | 4 ++-- src/dataframeit/errors.py | 29 ++++++++++++++++++----------- src/dataframeit/llm.py | 10 +++++++--- tests/test_errors.py | 31 +++++++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e6c85f..b0465691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado (#192). +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para `ProviderError`, `ProviderConfigurationError` e `ProviderOutputError`, e um '429' no texto da resposta deixa de reduzir os workers (#192). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). - A retomada de um modelo com `strict=True` deixa de acusar como incompatíveis os campos que o checkpoint grava em outra forma, como a data em texto, a tupla como lista, o `Enum` pelo valor e o inteiro que a coluna com linha pendente relê como float. A linha recusada pela validação configurada é validada de novo sem strict, e cada campo recusado só é aceito quando o valor validado diz o mesmo que o relido; `"3"` num campo `int` strict continua acusado. A volta ao tipo declarado passa a valer também para os campos escalares: data, `datetime`, `Enum`, `int` e `bool` relidos do CSV, do XLSX ou do parquet deixam de misturar o tipo da execução com o do arquivo. Os campos de `Enum` de valores em texto são relidos como texto do CSV e do XLSX, como os de `Literal`, e um valor como `"1"` deixa de voltar como número. O checkpoint grava o `Enum` escalar pelo valor; antes, o CSV e o XLSX gravavam `"Cor.AZUL"`, que a retomada recusava, e o parquet falhava ao gravar (#178). - A forma de `condition` em `json_schema_extra` é conferida antes de processar, com `ValueError` que diz o que é aceito: dict com `field` em texto e um operador (`equals`, `not_equals`, `in`, `not_in` ou `exists`), ou callable. Antes, `condition={}` e os demais valores falsos, como `False` ou `[]`, rodavam o campo sem aviso, embora `evaluate_condition` trate o dict vazio como condição falsa; `field` que não era texto levantava `AttributeError`; e as demais formas eram avisadas a cada linha, com o campo pulado (#163). diff --git a/docs/en/guides/error-handling.md b/docs/en/guides/error-handling.md index 4c84fa29..66e3b28f 100644 --- a/docs/en/guides/error-handling.md +++ b/docs/en/guides/error-handling.md @@ -68,14 +68,14 @@ The wait before attempt `n + 1` is `min(base_delay × 2^(n-1), max_delay)`, plus - **Connection error**: Network issues - **5xx errors**: Server problems -### Response rejected by validation (retry with the error) +### Response rejected by validation - **Validation error**: the response fails the Pydantic model, including custom validators (`model_validator`, `field_validator`) - **Parsing error**: the response is not valid JSON or did not come in the structured format With LangChain providers, the next attempt sends the model the rejected response and the list of errors, each with the field path and the rejected value, asking it to answer again fixing those points. Repeating the same prompt tends to repeat the same error. When the response is not JSON, the request carries the parser's error text. -With `claude_code`, the next attempt repeats the request, without the list of errors. With `codex`, a response that fails validation raises `ProviderOutputError`, and the row fails without a new attempt. In both, `_error_details` names the path and the rule of each error, without the rejected value. +With `claude_code`, the next attempt repeats the request, without the list of errors. With `codex`, a response that fails validation raises `ProviderOutputError`, and the row fails without a new attempt. In both, a validation failure writes to `_error_details` the path and the rule of each error, without the rejected value; path and rule still carry response text when Pydantic puts it there, such as a dict key or a custom validator's message. A `claude_code` parsing failure only records that the response holds no valid JSON. With OpenAI, the SDK validates the response inside the call and raises before returning the message; the rejected response and its tokens are read from the HTTP response attached to the error. When no raw response is available, the correction request goes with the prompt, carrying the rejected values. When a later attempt succeeds, tokens from the rejected ones are added to `_input_tokens` and `_output_tokens`, because they are billed too; if every attempt fails, the row gets status `error`, no token count, and `_error_details` names the field and the rule of each error. diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index b35627c6..145bc876 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -68,14 +68,14 @@ A espera antes da tentativa `n + 1` é `min(base_delay × 2^(n-1), max_delay)`, - **Erro de conexão**: Problemas de rede - **Erro 5xx**: Problemas no servidor -### Resposta recusada pela validação (retry com o erro) +### Resposta recusada pela validação - **Erro de validação**: a resposta não passa no modelo Pydantic, inclusive nos validadores próprios (`model_validator`, `field_validator`) - **Erro de parsing**: a resposta não é JSON válido ou não veio no formato estruturado Nos providers do LangChain, a tentativa seguinte leva ao modelo a resposta recusada e a lista de erros, cada um com o caminho do campo e o valor recusado, e pede que ele responda de novo corrigindo esses pontos. Repetir o mesmo prompt tende a repetir o mesmo erro. Quando a resposta não é JSON, o pedido leva o texto do erro do parser. -No `claude_code`, a tentativa seguinte repete o pedido, sem a lista de erros. No `codex`, a resposta que não passa na validação levanta `ProviderOutputError`, e a linha falha sem nova tentativa. Nos dois, `_error_details` diz o caminho e a regra de cada erro, sem o valor recusado. +No `claude_code`, a tentativa seguinte repete o pedido, sem a lista de erros. No `codex`, a resposta que não passa na validação levanta `ProviderOutputError`, e a linha falha sem nova tentativa. Nos dois, a falha de validação grava em `_error_details` o caminho e a regra de cada erro, sem o valor recusado; caminho e regra ainda trazem texto da resposta quando o Pydantic o põe ali, como a chave de um dict ou a mensagem de um validador próprio. A falha de parsing do `claude_code` grava só que a resposta não contém JSON válido. Na OpenAI, o SDK valida a resposta dentro da chamada e levanta o erro antes de devolver a mensagem; a resposta recusada e os tokens são lidos da resposta HTTP anexada ao erro. Quando nenhuma resposta bruta está disponível, o pedido de correção vai junto do prompt, com os valores recusados. Quando uma tentativa seguinte dá certo, os tokens das recusadas entram em `_input_tokens` e `_output_tokens`, porque também são cobrados; se todas falham, a linha fica com status `error`, sem contagem de tokens, e `_error_details` diz o campo e a regra de cada erro. diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index d6f050c3..46b083df 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -54,7 +54,9 @@ class ProviderOverloadedError(ProviderTransientError): class ProviderRejectedOutputError(ProviderTransientError, ValueError): - """Resposta recusada pela validação do modelo; a tentativa seguinte pede a correção. + """Resposta recusada pela validação do modelo; ganha nova tentativa. + + Nos providers do LangChain, a tentativa seguinte pede a correção. É transitória por classe, e não pela mensagem: um número como '404' no texto analisado faria a classificação por texto tratá-la como erro HTTP definitivo. @@ -83,21 +85,24 @@ class ProviderOutputError(ValueError): """Resposta definitiva incompatível com o contrato de saída.""" -# Teto de regras listadas na mensagem de uma resposta recusada. -_MAX_REJECTED_RULES = 20 +# Teto de erros de validação listados por resposta recusada, na mensagem da +# exceção e no pedido de correção do LangChain. +MAX_REJECTED_RULES = 20 def validation_error_summary(error: ValidationError) -> str: - """Caminho e regra de cada erro de validação, sem o valor recusado. + """Caminho e regra de cada erro de validação, sem o valor recusado (`input`). - A mensagem vai para `_error_details` e passa pela classificação de erros. O - valor recusado vem do texto analisado: levaria dado da linha para a coluna, e - um número como '401' nele seria tomado por status HTTP. + A mensagem vai para `_error_details`, e o valor recusado levaria dado da + linha para a coluna. O caminho e a regra ainda trazem texto da resposta + quando o Pydantic o põe ali: chave de dict, tag de união discriminada, + mensagem de validador próprio. Por isso a classificação das exceções que + carregam este resumo decide pela classe, e não pelo texto. """ rules = [ f"{'.'.join(str(part) for part in item.get('loc', ())) or '(resposta inteira)'}: " f"{item.get('msg', '')}" - for item in error.errors()[:_MAX_REJECTED_RULES] + for item in error.errors()[:MAX_REJECTED_RULES] ] return f"{error.error_count()} erro(s): {'; '.join(rules)}" @@ -438,8 +443,8 @@ def get_friendly_error_message( # noqa: C901, PLR0911 (uma saída por categoria return ( "RESPOSTA FORA DO CONTRATO DE SAÍDA\n" f"{error}\n" - "O provider respondeu, mas a resposta não serve ao modelo Pydantic, e a linha " - "não é tentada de novo. Veja as regras do modelo que falharam." + "O provider terminou sem uma resposta que sirva ao modelo Pydantic, e a linha " + "não é tentada de novo." ) error_str = f"{type(error).__name__}: {error}".lower() @@ -818,7 +823,9 @@ def is_rate_limit_error(error: Exception) -> bool: """ if isinstance(error, ProviderOverloadedError): return True - if isinstance(error, ProviderTransientError): + # As demais classes do pacote decidem pela classe: a mensagem de uma resposta + # recusada traz texto do modelo, em que '429' seria tomado por rate limit. + if isinstance(error, (ProviderError, ProviderConfigurationError, ProviderOutputError)): return False if _ModelRateLimitError is not None and isinstance(error, _ModelRateLimitError): return True diff --git a/src/dataframeit/llm.py b/src/dataframeit/llm.py index 9d838393..fdc6b49a 100644 --- a/src/dataframeit/llm.py +++ b/src/dataframeit/llm.py @@ -10,7 +10,12 @@ from langchain_core.exceptions import OutputParserException from pydantic import BaseModel, ValidationError -from .errors import ProviderRejectedOutputError, retry_with_backoff, validation_error_summary +from .errors import ( + MAX_REJECTED_RULES, + ProviderRejectedOutputError, + retry_with_backoff, + validation_error_summary, +) if TYPE_CHECKING: from collections.abc import Callable @@ -370,7 +375,6 @@ def _raw_payload(raw_message: object) -> tuple[dict | None, str]: # Tetos do pedido de correção: ele volta ao modelo a cada recusa, e um esquema # grande pode gerar dezenas de erros com trechos longos. -_MAX_ERRORS = 20 _MAX_INPUT_CHARS = 300 _MAX_ERROR_TEXT = 2000 @@ -395,7 +399,7 @@ def _validation_error_of( def _format_validation_error(error: ValidationError) -> str: """Uma linha por erro, com o caminho do campo e o valor recusado.""" lines = [] - for detail in error.errors()[:_MAX_ERRORS]: + for detail in error.errors()[:MAX_REJECTED_RULES]: location = ".".join(str(part) for part in detail.get("loc", ())) or "(resposta inteira)" value = json.dumps(detail.get("input"), ensure_ascii=False, default=str) if len(value) > _MAX_INPUT_CHARS: diff --git a/tests/test_errors.py b/tests/test_errors.py index 9116f44a..a3f25d83 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -3,9 +3,12 @@ import pytest from dataframeit.errors import ( + ProviderError, + ProviderOutputError, ProviderTransientError, _infer_provider_info, get_friendly_error_message, + is_rate_limit_error, retry_with_backoff, ) @@ -136,3 +139,31 @@ def funcao(): assert len(chamadas) == 2 assert resultado["_retry_info"]["retries"] == 1 + + +# O caminho de um erro de validação pode trazer texto da resposta, como a chave +# de um dict; um número nele não pode decidir a categoria do erro. +_CAMINHO_COM_NUMERO = ( + "Resposta do Codex não corresponde ao schema: 1 erro(s): " + "contagens.art. {numero} da CLT: Input should be a valid integer" +) + + +def test_resposta_fora_do_contrato_nao_vira_erro_de_credencial(): + erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=401)) + + amigavel = get_friendly_error_message(erro, "codex") + + assert amigavel.startswith("RESPOSTA FORA DO CONTRATO DE SAÍDA") + assert "login" not in amigavel + + +@pytest.mark.parametrize( + "erro", + [ + ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=429)), + ProviderError("Claude Code falhou: art. 429 do CPC"), + ], +) +def test_erro_definitivo_nao_conta_como_rate_limit_pelo_texto(erro): + assert is_rate_limit_error(erro) is False From 97e43f819d9e813a2a4b4948e66f673be88adc57 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 13:08:40 -0300 Subject: [PATCH 03/11] =?UTF-8?q?CHANGELOG=20com=20a=20vers=C3=A3o=20atual?= =?UTF-8?q?=20do=20openai-codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A entrada do extra codex citava o pin 0.159.2; o pyproject.toml fixa openai-codex==0.160.1, que instala o runtime 0.160.1. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe40ded..3097c9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - O resumo de uso do fim da execução deixa de sair por `print` no stdout e passa ao logger `dataframeit.stats`, em nível INFO. Sem logging configurado, ele sai em stderr; `logging.getLogger("dataframeit.stats").setLevel(logging.WARNING)` o silencia e mantém as colunas de tokens. O aviso de workers reduzidos por rate limit vira `UserWarning`, e a mensagem amigável do provider passa a fazer parte do aviso de falha da linha, ambos filtráveis por `warnings` e `pytest -W` (#181). - `dataframeit()` declara o tipo de retorno pelo tipo da entrada: DataFrame ou Series do polars devolve DataFrame do polars, e DataFrame ou Series do pandas, `list` e `dict` devolvem DataFrame do pandas. Antes, o retorno era a união dos dois, e o checker de tipos recusava `resultado["coluna"].tolist()` (#155). - O piso do pydantic sobe para 2.12. Na 2.11, uma `default_factory` que lê outro campo é chamada com os dados validados vazios quando esse campo falhou, e a retomada levantava o `KeyError` dela em vez do erro de campo incompatível (#154). -- O extra `codex` passa a exigir `openai-codex==0.159.2`, que traz o runtime 0.159.2. Com o runtime 0.137, que o SDK anterior fixava, a conta do ChatGPT recebia HTTP 400 nos modelos lançados depois dele, como o `gpt-6-luna`, embora o catálogo da conta os anunciasse. O `effort` é conferido contra os valores que o SDK declara, agora com `max` e `ultra`, porque o enum do SDK novo aceita qualquer texto. +- O extra `codex` passa a exigir `openai-codex==0.160.1`, que traz o runtime 0.160.1. Com o runtime 0.137, que o SDK anterior fixava, a conta do ChatGPT recebia HTTP 400 nos modelos lançados depois dele, como o `gpt-6-luna`, embora o catálogo da conta os anunciasse. O `effort` é conferido contra os valores que o SDK declara, agora com `max` e `ultra`, porque o enum do SDK novo aceita qualquer texto. - No provider `codex`, os códigos de erro `rateLimitExceeded` e `flexUnavailable` do runtime levantam `ProviderOverloadedError`, e a linha é repetida como no HTTP 429. - No provider `codex`, as flags `sleep_tool` e `view_image` do runtime ficam desligadas, e as ferramentas de pausa e de leitura de imagem local deixam de ser oferecidas ao modelo. - No provider `codex`, a flag `unbounded_connection_retries` do runtime fica desligada. Ligada, uma falha de conexão deixa o turno esperando para sempre; desligada, a linha falha com `ProviderTransientError` e é repetida conforme `max_retries`. From 80ccf2b4245b49062cb009f9665982fff9c9ff9a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 13:09:00 -0300 Subject: [PATCH 04/11] =?UTF-8?q?is=5Frate=5Flimit=5Ferror=20decide=20pela?= =?UTF-8?q?=20classe=20s=C3=B3=20na=20resposta=20recusada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tupla tinha ganhado ProviderError e ProviderConfigurationError além de ProviderOutputError. Com ProviderError nela, um turno do codex que falha com "429 Too Many Requests" no texto e sem código classificável deixava de reduzir os workers, embora a mensagem amigável dissesse limite de requisições. Tirar ProviderConfigurationError não derrubava teste. Fica o que a issue pede: a resposta fora do contrato, cujo resumo de validação traz texto do modelo, decide pela classe, e o ProviderError definitivo volta a ser lido pelo texto. Um teste prende cada lado. --- CHANGELOG.md | 2 +- src/dataframeit/errors.py | 8 +++++--- tests/test_errors.py | 19 +++++++++++-------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3097c9dd..60703413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para `ProviderError`, `ProviderConfigurationError` e `ProviderOutputError`, e um '429' no texto da resposta deixa de reduzir os workers (#192). +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para `ProviderOutputError`, e um '429' no caminho de um erro de validação do `codex`, como a chave de um dict, deixa de reduzir os workers (#192). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index 46b083df..0b7fec4a 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -823,9 +823,11 @@ def is_rate_limit_error(error: Exception) -> bool: """ if isinstance(error, ProviderOverloadedError): return True - # As demais classes do pacote decidem pela classe: a mensagem de uma resposta - # recusada traz texto do modelo, em que '429' seria tomado por rate limit. - if isinstance(error, (ProviderError, ProviderConfigurationError, ProviderOutputError)): + # As transitórias decidem pela classe, e a resposta fora do contrato também: + # o resumo da validação traz texto do modelo, em que '429' seria tomado por + # rate limit. O ProviderError definitivo segue para o texto, porque um turno + # do codex sem código classificável só diz "429 Too Many Requests" na mensagem. + if isinstance(error, (ProviderTransientError, ProviderOutputError)): return False if _ModelRateLimitError is not None and isinstance(error, _ModelRateLimitError): return True diff --git a/tests/test_errors.py b/tests/test_errors.py index a3f25d83..451b796a 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -158,12 +158,15 @@ def test_resposta_fora_do_contrato_nao_vira_erro_de_credencial(): assert "login" not in amigavel -@pytest.mark.parametrize( - "erro", - [ - ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=429)), - ProviderError("Claude Code falhou: art. 429 do CPC"), - ], -) -def test_erro_definitivo_nao_conta_como_rate_limit_pelo_texto(erro): +def test_numero_no_caminho_da_validacao_nao_conta_como_rate_limit(): + erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=429)) + assert is_rate_limit_error(erro) is False + + +# O turno do codex que falha sem código classificável só traz o 429 no texto, e +# a redução de workers depende de a mensagem ser lida. +def test_erro_definitivo_com_429_na_mensagem_conta_como_rate_limit(): + erro = ProviderError("Turno Codex falhou: 429 Too Many Requests") + + assert is_rate_limit_error(erro) is True From dfa197f84cf5d38882ec9233ba0a81358481c624 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 13:09:00 -0300 Subject: [PATCH 05/11] =?UTF-8?q?Coment=C3=A1rios=20e=20testes=20da=20mens?= =?UTF-8?q?agem=20da=20resposta=20recusada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois comentários diziam que a mensagem não traz texto da resposta. Traz: o resumo da validação leva a chave de dict recusada e a mensagem de validador próprio, e por isso a classificação decide pela classe. A mensagem amigável nova não tinha teste. Voltar o texto que prometia o erro de volta ao modelo, ou tirar o detalhe do erro da mensagem de ProviderOutputError, passava na suíte; agora cada mudança derruba um teste. --- src/dataframeit/claude_code.py | 6 ++++-- src/dataframeit/errors.py | 7 ++++--- tests/test_errors.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/dataframeit/claude_code.py b/src/dataframeit/claude_code.py index 8a43ff93..73239b4c 100644 --- a/src/dataframeit/claude_code.py +++ b/src/dataframeit/claude_code.py @@ -238,8 +238,10 @@ def _call() -> dict: msg = "Claude Code SDK retornou resposta vazia" raise ValueError(msg) - # A recusa sai como ProviderRejectedOutputError, transitória pela classe e - # sem o texto da resposta na mensagem (ver validation_error_summary). + # A recusa sai como ProviderRejectedOutputError, transitória pela classe. + # A mensagem não leva o valor recusado, mas na falha de validação o + # caminho e a regra trazem texto da resposta quando o Pydantic o põe + # ali (ver validation_error_summary). try: parsed = parse_json(response_text) except ValueError as err: diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index 0b7fec4a..90b69055 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -429,9 +429,10 @@ def get_friendly_error_message( # noqa: C901, PLR0911 (uma saída por categoria Returns: Mensagem de erro amigável com instruções de como resolver. """ - # As duas classes de resposta recusada decidem pela classe: a mensagem não - # traz o texto analisado, mas um número como '401' no caminho de um campo - # não pode virar erro de chave. + # As duas classes de resposta recusada decidem pela classe: a mensagem pode + # trazer texto da resposta no caminho e na regra de um erro de validação + # (ver validation_error_summary), e um número como '401' na chave de um dict + # não pode virar erro de credencial. if isinstance(error, ProviderRejectedOutputError): return ( "RESPOSTA RECUSADA PELA VALIDAÇÃO DO MODELO PYDANTIC\n" diff --git a/tests/test_errors.py b/tests/test_errors.py index 451b796a..0d44bb6f 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -5,6 +5,7 @@ from dataframeit.errors import ( ProviderError, ProviderOutputError, + ProviderRejectedOutputError, ProviderTransientError, _infer_provider_info, get_friendly_error_message, @@ -158,6 +159,23 @@ def test_resposta_fora_do_contrato_nao_vira_erro_de_credencial(): assert "login" not in amigavel +def test_mensagem_da_resposta_fora_do_contrato_traz_o_detalhe_do_erro(): + erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=401)) + + assert str(erro) in get_friendly_error_message(erro, "codex") + + +# Só os providers do LangChain levam o erro de volta ao modelo; o claude_code +# repete o pedido, e a mensagem da classe vale para os dois caminhos. +def test_mensagem_da_resposta_recusada_nao_promete_retorno_ao_modelo(): + erro = ProviderRejectedOutputError("Falha na validação da resposta: 1 erro(s): campo: regra") + + amigavel = get_friendly_error_message(erro, "claude_code") + + assert str(erro) in amigavel + assert "ao modelo" not in amigavel.lower() + + def test_numero_no_caminho_da_validacao_nao_conta_como_rate_limit(): erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=429)) From 7ab9a6ec3c6d3d91fbaa310b6f79d5fe6f5f65a9 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Oct 2026 13:36:55 -0300 Subject: [PATCH 06/11] Exemplos da resposta recusada que valem para cada provider e testes do resumo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O codex recusa modelo com dict de chaves dinâmicas antes de processar, e o exemplo da chave de dict só acontece no claude_code. O CHANGELOG, o guia de erros e a fixture dos testes passam a usar a mensagem de um validador próprio para o codex. Os READMEs dizem que a resposta recusada volta ao modelo só nos providers do LangChain. Testes novos: o resumo da validação conta todos os erros e lista até o teto, e a mensagem da resposta fora do contrato diz que a linha não é tentada de novo. --- CHANGELOG.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- docs/en/guides/error-handling.md | 2 +- docs/guides/error-handling.md | 2 +- tests/test_errors.py | 37 ++++++++++++++++++++++++-------- 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60703413..8cafe978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para `ProviderOutputError`, e um '429' no caminho de um erro de validação do `codex`, como a chave de um dict, deixa de reduzir os workers (#192). +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para `ProviderOutputError`, e um '429' no resumo de um erro de validação do `codex`, como na mensagem de um validador próprio, deixa de reduzir os workers (#192). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). - O operando de `in` e `not_in` em `condition` é conferido antes de processar, com `ValueError` quando não é lista, tupla, set ou frozenset. Antes, um operando que não era coleção, como `{"in": 5}`, levantava `TypeError` em cada linha depois das chamadas dos campos anteriores, e um texto, como `{"in": "pfj"}`, fazia busca de substring e aceitava `"pf"` (#182). diff --git a/README.md b/README.md index 30f7cdc0..ef63496d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Field and class names are arbitrary; the examples in the [`example/`](https://gi - **Multiple providers**: OpenAI, Google Gemini, Anthropic and Groq with their own extras, any other LangChain provider (Cohere, Mistral, Vertex AI, Bedrock, Azure) by installing its package, plus Claude Code and Codex through their official SDKs - **Multiple input types**: pandas or Polars DataFrame and Series, list, dict -- **Structured output**: Pydantic validation; a rejected answer goes back to the model with the error +- **Structured output**: Pydantic validation; with the LangChain providers, a rejected answer goes back to the model with the error - **Resilience**: Automatic retry with exponential backoff, and periodic checkpoints (`batch_size` + `checkpoint_path`) to resume long runs - **Performance**: Parallel processing that halves workers on rate limits, configurable rate limiting - **Web search**: Tavily or Exa, per field or per group of fields, with conditional fields (`condition`, `depends_on`) diff --git a/README.pt-BR.md b/README.pt-BR.md index 16c56961..0e8be7d3 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -76,7 +76,7 @@ Os nomes de classes e campos são arbitrários: os notebooks de [`example/`](exa - **Múltiplos providers**: OpenAI, Google Gemini, Anthropic e Groq com extras próprios, qualquer outro provider do LangChain (Cohere, Mistral, Vertex AI, Bedrock, Azure) instalando o pacote dele, além de Claude Code e Codex pelos SDKs oficiais - **Múltiplos tipos de entrada**: DataFrame e Series do pandas ou do Polars, list, dict -- **Saída estruturada**: Validação com Pydantic; uma resposta recusada volta ao modelo com o erro +- **Saída estruturada**: Validação com Pydantic; nos providers do LangChain, uma resposta recusada volta ao modelo com o erro - **Resiliência**: Retry automático com backoff exponencial e checkpoints periódicos (`batch_size` + `checkpoint_path`) para retomar execuções longas - **Performance**: Processamento paralelo que reduz os workers pela metade em rate limit, rate limiting configurável - **Busca web**: Tavily ou Exa, por campo ou por grupo de campos, com campos condicionais (`condition`, `depends_on`) diff --git a/docs/en/guides/error-handling.md b/docs/en/guides/error-handling.md index 66e3b28f..88360b8c 100644 --- a/docs/en/guides/error-handling.md +++ b/docs/en/guides/error-handling.md @@ -75,7 +75,7 @@ The wait before attempt `n + 1` is `min(base_delay × 2^(n-1), max_delay)`, plus With LangChain providers, the next attempt sends the model the rejected response and the list of errors, each with the field path and the rejected value, asking it to answer again fixing those points. Repeating the same prompt tends to repeat the same error. When the response is not JSON, the request carries the parser's error text. -With `claude_code`, the next attempt repeats the request, without the list of errors. With `codex`, a response that fails validation raises `ProviderOutputError`, and the row fails without a new attempt. In both, a validation failure writes to `_error_details` the path and the rule of each error, without the rejected value; path and rule still carry response text when Pydantic puts it there, such as a dict key or a custom validator's message. A `claude_code` parsing failure only records that the response holds no valid JSON. +With `claude_code`, the next attempt repeats the request, without the list of errors. With `codex`, a response that fails validation raises `ProviderOutputError`, and the row fails without a new attempt. In both, a validation failure writes to `_error_details` the path and the rule of each error, without the rejected value; path and rule still carry response text when Pydantic puts it there, such as a custom validator's message and, with `claude_code`, a dict key. A `claude_code` parsing failure only records that the response holds no valid JSON. With OpenAI, the SDK validates the response inside the call and raises before returning the message; the rejected response and its tokens are read from the HTTP response attached to the error. When no raw response is available, the correction request goes with the prompt, carrying the rejected values. When a later attempt succeeds, tokens from the rejected ones are added to `_input_tokens` and `_output_tokens`, because they are billed too; if every attempt fails, the row gets status `error`, no token count, and `_error_details` names the field and the rule of each error. diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 145bc876..223063ba 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -75,7 +75,7 @@ A espera antes da tentativa `n + 1` é `min(base_delay × 2^(n-1), max_delay)`, Nos providers do LangChain, a tentativa seguinte leva ao modelo a resposta recusada e a lista de erros, cada um com o caminho do campo e o valor recusado, e pede que ele responda de novo corrigindo esses pontos. Repetir o mesmo prompt tende a repetir o mesmo erro. Quando a resposta não é JSON, o pedido leva o texto do erro do parser. -No `claude_code`, a tentativa seguinte repete o pedido, sem a lista de erros. No `codex`, a resposta que não passa na validação levanta `ProviderOutputError`, e a linha falha sem nova tentativa. Nos dois, a falha de validação grava em `_error_details` o caminho e a regra de cada erro, sem o valor recusado; caminho e regra ainda trazem texto da resposta quando o Pydantic o põe ali, como a chave de um dict ou a mensagem de um validador próprio. A falha de parsing do `claude_code` grava só que a resposta não contém JSON válido. +No `claude_code`, a tentativa seguinte repete o pedido, sem a lista de erros. No `codex`, a resposta que não passa na validação levanta `ProviderOutputError`, e a linha falha sem nova tentativa. Nos dois, a falha de validação grava em `_error_details` o caminho e a regra de cada erro, sem o valor recusado; caminho e regra ainda trazem texto da resposta quando o Pydantic o põe ali, como a mensagem de um validador próprio e, no `claude_code`, a chave de um dict. A falha de parsing do `claude_code` grava só que a resposta não contém JSON válido. Na OpenAI, o SDK valida a resposta dentro da chamada e levanta o erro antes de devolver a mensagem; a resposta recusada e os tokens são lidos da resposta HTTP anexada ao erro. Quando nenhuma resposta bruta está disponível, o pedido de correção vai junto do prompt, com os valores recusados. Quando uma tentativa seguinte dá certo, os tokens das recusadas entram em `_input_tokens` e `_output_tokens`, porque também são cobrados; se todas falham, a linha fica com status `error`, sem contagem de tokens, e `_error_details` diz o campo e a regra de cada erro. diff --git a/tests/test_errors.py b/tests/test_errors.py index 0d44bb6f..92a52778 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,8 +1,10 @@ """Testes para inferência de provider em dataframeit.errors.""" import pytest +from pydantic import ValidationError, create_model from dataframeit.errors import ( + MAX_REJECTED_RULES, ProviderError, ProviderOutputError, ProviderRejectedOutputError, @@ -11,6 +13,7 @@ get_friendly_error_message, is_rate_limit_error, retry_with_backoff, + validation_error_summary, ) @@ -142,16 +145,17 @@ def funcao(): assert resultado["_retry_info"]["retries"] == 1 -# O caminho de um erro de validação pode trazer texto da resposta, como a chave -# de um dict; um número nele não pode decidir a categoria do erro. -_CAMINHO_COM_NUMERO = ( +# O resumo de um erro de validação pode trazer texto da resposta, como a +# mensagem de um validador próprio; um número nele não pode decidir a categoria +# do erro. +_RESUMO_COM_NUMERO = ( "Resposta do Codex não corresponde ao schema: 1 erro(s): " - "contagens.art. {numero} da CLT: Input should be a valid integer" + "fundamento: Value error, art. {numero} da CLT não consta da lista" ) def test_resposta_fora_do_contrato_nao_vira_erro_de_credencial(): - erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=401)) + erro = ProviderOutputError(_RESUMO_COM_NUMERO.format(numero=401)) amigavel = get_friendly_error_message(erro, "codex") @@ -160,9 +164,24 @@ def test_resposta_fora_do_contrato_nao_vira_erro_de_credencial(): def test_mensagem_da_resposta_fora_do_contrato_traz_o_detalhe_do_erro(): - erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=401)) + erro = ProviderOutputError(_RESUMO_COM_NUMERO.format(numero=401)) + amigavel = get_friendly_error_message(erro, "codex") + + assert str(erro) in amigavel + assert "não é tentada de novo" in amigavel + + +def test_resumo_da_validacao_conta_todos_os_erros_e_lista_ate_o_teto(): + campos = {f"campo_{i}": (int, ...) for i in range(MAX_REJECTED_RULES + 5)} + # Campos por **kwargs, que o ty não casa com as sobrecargas de create_model. + modelo = create_model("MuitosCampos", **campos) # ty: ignore[no-matching-overload] + with pytest.raises(ValidationError) as recusa: + modelo.model_validate({}) + + resumo = validation_error_summary(recusa.value) - assert str(erro) in get_friendly_error_message(erro, "codex") + assert resumo.startswith(f"{MAX_REJECTED_RULES + 5} erro(s): ") + assert resumo.count("Field required") == MAX_REJECTED_RULES # Só os providers do LangChain levam o erro de volta ao modelo; o claude_code @@ -176,8 +195,8 @@ def test_mensagem_da_resposta_recusada_nao_promete_retorno_ao_modelo(): assert "ao modelo" not in amigavel.lower() -def test_numero_no_caminho_da_validacao_nao_conta_como_rate_limit(): - erro = ProviderOutputError(_CAMINHO_COM_NUMERO.format(numero=429)) +def test_numero_no_resumo_da_validacao_nao_conta_como_rate_limit(): + erro = ProviderOutputError(_RESUMO_COM_NUMERO.format(numero=429)) assert is_rate_limit_error(erro) is False From 8ebaee8e02fe7be627180647625eaef60b9b6b47 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 09:33:40 -0300 Subject: [PATCH 07/11] is_rate_limit_error decide pela classe para todo ProviderError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rate limit do pacote é só ProviderOverloadedError. A mensagem de um ProviderError do claude_code leva o campo result do SDK, que pode trazer texto do modelo, e um "429" ali reduzia os workers sem haver limite de requisições. Os demais ProviderError deixam de ser lidos pelo texto, como o ProviderOutputError já não era. Um turno do codex que falha só com "429 Too Many Requests" no texto, sem código classificável, deixa de reduzir os workers. O teste que prendia o comportamento anterior troca de lado, e um teste novo monta o erro do claude_code como _raise_for_result_error o monta. --- CHANGELOG.md | 2 +- src/dataframeit/errors.py | 12 +++++++----- tests/test_errors.py | 36 ++++++++++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 081efc10..175d866e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para `ProviderOutputError`, e um '429' no resumo de um erro de validação do `codex`, como na mensagem de um validador próprio, deixa de reduzir os workers (#192). +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para todo `ProviderError` e para o `ProviderOutputError`, e entre eles só o `ProviderOverloadedError` conta como rate limit: um '429' no texto, como no resumo de um erro de validação do `codex` ou no campo `result` que o SDK do Claude Code devolve num erro, deixa de reduzir os workers. Um turno do `codex` que falha só com '429 Too Many Requests' no texto, sem código classificável, também deixa de reduzir os workers (#192). - Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o polars não reúne numa coluna tipada, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` e `dict[str, Any]` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float, inteiro acima do `Int64` e `datetime` com fuso de deslocamento, como `+05:30`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. O campo `Enum` sai pelo valor do membro, inclusive na coluna `Object` (#218). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index 90b69055..192c372e 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -824,11 +824,13 @@ def is_rate_limit_error(error: Exception) -> bool: """ if isinstance(error, ProviderOverloadedError): return True - # As transitórias decidem pela classe, e a resposta fora do contrato também: - # o resumo da validação traz texto do modelo, em que '429' seria tomado por - # rate limit. O ProviderError definitivo segue para o texto, porque um turno - # do codex sem código classificável só diz "429 Too Many Requests" na mensagem. - if isinstance(error, (ProviderTransientError, ProviderOutputError)): + # Rate limit do pacote é só ProviderOverloadedError. Os demais ProviderError e + # o ProviderOutputError decidem pela classe, sem ler o texto: a mensagem deles + # pode trazer texto do modelo, como o resumo da validação e o campo result do + # SDK no erro do claude_code, e um '429' ali seria tomado por rate limit. Um + # turno do codex que falha só com "429 Too Many Requests" no texto, sem código + # classificável, não reduz os workers. + if isinstance(error, (ProviderError, ProviderOutputError)): return False if _ModelRateLimitError is not None and isinstance(error, _ModelRateLimitError): return True diff --git a/tests/test_errors.py b/tests/test_errors.py index 92a52778..823a81f5 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,8 +1,11 @@ """Testes para inferência de provider em dataframeit.errors.""" +from types import SimpleNamespace + import pytest from pydantic import ValidationError, create_model +from dataframeit.claude_code import _raise_for_result_error from dataframeit.errors import ( MAX_REJECTED_RULES, ProviderError, @@ -201,9 +204,30 @@ def test_numero_no_resumo_da_validacao_nao_conta_como_rate_limit(): assert is_rate_limit_error(erro) is False -# O turno do codex que falha sem código classificável só traz o 429 no texto, e -# a redução de workers depende de a mensagem ser lida. -def test_erro_definitivo_com_429_na_mensagem_conta_como_rate_limit(): - erro = ProviderError("Turno Codex falhou: 429 Too Many Requests") - - assert is_rate_limit_error(erro) is True +# Rate limit do pacote é só ProviderOverloadedError. O ProviderError do +# claude_code leva na mensagem o campo result do SDK, que pode trazer texto do +# modelo, e um "429" ali não pode reduzir os workers. +def test_429_no_result_do_claude_code_nao_conta_como_rate_limit(): + resultado = SimpleNamespace( + is_error=True, + subtype="error_max_turns", + api_error_status=None, + errors=None, + result="Fundamento: art. 429 do CPC", + ) + with pytest.raises(ProviderError) as falha: + _raise_for_result_error(resultado) + + assert type(falha.value) is ProviderError + assert "art. 429 do CPC" in str(falha.value) + assert is_rate_limit_error(falha.value) is False + + +# Nenhum ProviderError é lido pelo texto, nem o turno do codex que falha só com +# "429 Too Many Requests" na mensagem, sem código classificável. +@pytest.mark.parametrize( + "mensagem", + ["Turno Codex falhou: 429 Too Many Requests", "Claude Code falhou: art. 429 do CPC"], +) +def test_provider_error_com_429_na_mensagem_nao_conta_como_rate_limit(mensagem): + assert is_rate_limit_error(ProviderError(mensagem)) is False From f0c3f1f93bd9687436ff0f8dd019053da9290403 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 10:55:35 -0300 Subject: [PATCH 08/11] =?UTF-8?q?C=C3=B3digo=20de=20rate=20limit=20no=20er?= =?UTF-8?q?ro=20JSON-RPC=20do=20codex=20levanta=20ProviderOverloadedError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O is_retryable_error do SDK reconhece só server_overloaded, em snake_case, no data do erro JSON-RPC. Um CodexRpcError com rate_limit_exceeded saía ProviderError e, com is_rate_limit_error decidindo pela classe, deixava de reduzir os workers. O data passa a ser lido pela mesma regra do turno que falha (_is_overload_info), nas duas grafias do código, sem ler a mensagem. --- CHANGELOG.md | 2 +- src/dataframeit/codex.py | 126 +++++++++++++++++++++++++++++--------- src/dataframeit/errors.py | 5 +- tests/test_codex.py | 71 +++++++++++++++++++++ 4 files changed, 172 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28638216..b1ccb5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para todo `ProviderError` e para o `ProviderOutputError`, e entre eles só o `ProviderOverloadedError` conta como rate limit: um '429' no texto, como no resumo de um erro de validação do `codex` ou no campo `result` que o SDK do Claude Code devolve num erro, deixa de reduzir os workers. Um turno do `codex` que falha só com '429 Too Many Requests' no texto, sem código classificável, também deixa de reduzir os workers (#192). +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para todo `ProviderError` e para o `ProviderOutputError`, e entre eles só o `ProviderOverloadedError` conta como rate limit: um '429' no texto, como no resumo de um erro de validação do `codex` ou no campo `result` que o SDK do Claude Code devolve num erro, deixa de reduzir os workers. No `codex`, o erro JSON-RPC que traz no `data` um código de rate limit, como `rate_limit_exceeded`, levanta `ProviderOverloadedError`, como o turno que falha com o mesmo código, e a linha é repetida; antes, o SDK só reconhecia `server_overloaded`, e o erro falhava a linha sem nova tentativa. O erro do `codex`, de turno ou JSON-RPC, que traz '429 Too Many Requests' só no texto, sem código classificável, deixa de reduzir os workers (#192). - Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o polars não reúne numa coluna tipada, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float, inteiro acima do `Int64` e `datetime` com fuso de deslocamento, como `+05:30`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. O campo `Enum` sai pelo valor do membro, inclusive na coluna `Object` (#218). - A coluna `_trace` do agente único fica com as demais colunas de trace na ordem da saída, depois dos campos do modelo. Antes, numa entrada que já a trazia, os campos novos saíam depois dela. - Com entrada polars, as colunas que a execução não escreve saem do DataFrame de entrada, com o tipo e os valores de lá. Antes, a saída inteira era convertida de volta do pandas: um `Int64` com nulo voltava `Float64`, o que arredondava ids acima de 2**53, um `Date` voltava `Datetime`, e `Enum`, `Decimal`, `Array`, `Null` e o inteiro dentro de `Struct` e `List` também trocavam de tipo; uma coluna `Object` também trocava de tipo ou, com objetos que o Arrow não infere, levantava `ArrowInvalid` depois das chamadas (#195). diff --git a/src/dataframeit/codex.py b/src/dataframeit/codex.py index e95dab6a..4e664bc8 100644 --- a/src/dataframeit/codex.py +++ b/src/dataframeit/codex.py @@ -901,10 +901,6 @@ def _raise_turn_error(error: TurnError | None) -> NoReturn: """Classifica o turno que falhou pelo `codex_error_info` que o runtime enviou.""" from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) CodexErrorInfoValue, - HttpConnectionFailedCodexErrorInfo, - ResponseStreamConnectionFailedCodexErrorInfo, - ResponseStreamDisconnectedCodexErrorInfo, - ResponseTooManyFailedAttemptsCodexErrorInfo, ) if error is None: @@ -915,21 +911,37 @@ def _raise_turn_error(error: TurnError | None) -> NoReturn: root = getattr(error.codex_error_info, "root", None) if root is CodexErrorInfoValue.usage_limit_exceeded: raise ProviderUsageLimitError(message) - - # Sobrecarga, limite de requisições e falta de capacidade do tier flex passam - # com o tempo: a linha é repetida com backoff, como no HTTP 429. - overload_codes = { - CodexErrorInfoValue.server_overloaded, - CodexErrorInfoValue.rate_limit_exceeded, - CodexErrorInfoValue.flex_unavailable, - } - if isinstance(root, CodexErrorInfoValue) and root in overload_codes: + if _is_overload_info(root): raise ProviderOverloadedError(message) transient_codes = { CodexErrorInfoValue.internal_server_error, CodexErrorInfoValue.thread_rollback_failed, } + is_http, status = _http_variant_status(root) + if is_http: + if status is None or status >= _HTTP_SERVER_ERROR_MIN: + raise ProviderTransientError(message) + raise ProviderError(message) + + if isinstance(root, CodexErrorInfoValue) and root in transient_codes: + raise ProviderTransientError(message) + + # Os demais códigos ficam como falha da linha. Entre eles, a violação de + # política vem do conteúdo da requisição, e as recusas acumuladas e o + # orçamento da sessão contam por thread, que é própria de cada linha. + raise ProviderError(message) + + +def _http_variant_status(root: object) -> tuple[bool, int | None]: + """Diz se o `codex_error_info` é variante HTTP e devolve o status dela, que pode faltar.""" + from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) + HttpConnectionFailedCodexErrorInfo, + ResponseStreamConnectionFailedCodexErrorInfo, + ResponseStreamDisconnectedCodexErrorInfo, + ResponseTooManyFailedAttemptsCodexErrorInfo, + ) + http_variants = ( (HttpConnectionFailedCodexErrorInfo, "http_connection_failed"), ( @@ -946,34 +958,90 @@ def _raise_turn_error(error: TurnError | None) -> NoReturn: ), ) for variant_type, payload_field in http_variants: - if not isinstance(root, variant_type): - continue - status = getattr(root, payload_field).http_status_code - if status == _HTTP_TOO_MANY_REQUESTS: - raise ProviderOverloadedError(message) - if status is None or status >= _HTTP_SERVER_ERROR_MIN: - raise ProviderTransientError(message) - raise ProviderError(message) + if isinstance(root, variant_type): + return True, getattr(root, payload_field).http_status_code + return False, None - if isinstance(root, CodexErrorInfoValue) and root in transient_codes: - raise ProviderTransientError(message) - # Os demais códigos ficam como falha da linha. Entre eles, a violação de - # política vem do conteúdo da requisição, e as recusas acumuladas e o - # orçamento da sessão contam por thread, que é própria de cada linha. - raise ProviderError(message) +def _is_overload_info(root: object) -> bool: + """Diz se o `codex_error_info` é de sobrecarga ou de rate limit. + + Sobrecarga, limite de requisições, falta de capacidade do tier flex e o + HTTP 429 de uma variante HTTP passam com o tempo: a linha é repetida com + backoff, e os workers caem. + """ + from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) + CodexErrorInfoValue, + ) + + if isinstance(root, CodexErrorInfoValue): + return root in { + CodexErrorInfoValue.server_overloaded, + CodexErrorInfoValue.rate_limit_exceeded, + CodexErrorInfoValue.flex_unavailable, + } + return _http_variant_status(root) == (True, _HTTP_TOO_MANY_REQUESTS) + + +# Chaves em que o `is_retryable_error` do SDK procura o `codex_error_info` no +# `data` de um erro JSON-RPC. +_RPC_ERROR_INFO_KEYS = ("codex_error_info", "codexErrorInfo", "errorInfo") + + +def _parse_error_info(value: object) -> object: + """Converte o `codex_error_info` de um erro JSON-RPC na raiz do `CodexErrorInfo`. + + Aceita o código nas duas grafias: camelCase, a do protocolo v2, que o + `CodexErrorInfo` valida, e snake_case, a que o SDK procura. Devolve `None` + quando o valor não é um código conhecido. + """ + from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) + CodexErrorInfo, + CodexErrorInfoValue, + ) + + if isinstance(value, str) and value in CodexErrorInfoValue.__members__: + return CodexErrorInfoValue[value] + try: + return CodexErrorInfo.model_validate(value).root + except ValidationError: + return None + + +def _rpc_error_is_overload(data: object) -> bool: + """Diz se o `data` de um erro JSON-RPC traz código de sobrecarga ou de rate limit. + + Procura onde o `is_retryable_error` do SDK procura: nas chaves de + `_RPC_ERROR_INFO_KEYS` do `data` e de cada dict dentro dele, inclusive em + listas. O SDK reconhece só `server_overloaded`, e um `rate_limit_exceeded` + sairia como erro definitivo; aqui vale a regra de `_is_overload_info`, a + mesma do turno que falha. A mensagem do erro não é lida. + """ + if isinstance(data, list): + return any(_rpc_error_is_overload(item) for item in data) + if not isinstance(data, dict): + return False + for key in _RPC_ERROR_INFO_KEYS: + if key in data and _is_overload_info(_parse_error_info(data[key])): + return True + return any(_rpc_error_is_overload(value) for value in data.values()) def _raise_classified_sdk_error(error: Exception) -> NoReturn: from openai_codex import is_retryable_error # noqa: PLC0415 (extra codex opcional) - from openai_codex.errors import TransportClosedError # noqa: PLC0415 (extra codex opcional) + from openai_codex.errors import ( # noqa: PLC0415 (extra codex opcional) + JsonRpcError, + TransportClosedError, + ) message = f"{type(error).__name__}: {error}" # Com o app-server encerrado, toda linha seguinte falharia do mesmo jeito. if isinstance(error, (TransportClosedError, BrokenPipeError)): msg = f"O app-server do Codex encerrou: {message}" raise ProviderAbortError(msg) from error - if is_retryable_error(error): + if is_retryable_error(error) or ( + isinstance(error, JsonRpcError) and _rpc_error_is_overload(error.data) + ): raise ProviderOverloadedError(message) from error raise ProviderError(message) from error diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index 192c372e..5559a344 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -827,8 +827,9 @@ def is_rate_limit_error(error: Exception) -> bool: # Rate limit do pacote é só ProviderOverloadedError. Os demais ProviderError e # o ProviderOutputError decidem pela classe, sem ler o texto: a mensagem deles # pode trazer texto do modelo, como o resumo da validação e o campo result do - # SDK no erro do claude_code, e um '429' ali seria tomado por rate limit. Um - # turno do codex que falha só com "429 Too Many Requests" no texto, sem código + # SDK no erro do claude_code, e um '429' ali seria tomado por rate limit. O + # codex classifica o rate limit pelo código do turno ou do erro JSON-RPC, e o + # erro que traz "429 Too Many Requests" só no texto, sem código # classificável, não reduz os workers. if isinstance(error, (ProviderError, ProviderOutputError)): return False diff --git a/tests/test_codex.py b/tests/test_codex.py index 38adccb9..c18195e5 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -27,6 +27,7 @@ CodexBackend, _AppServerStop, _build_schema, + _raise_classified_sdk_error, _to_strict_json_schema, _toml_string, _turn_timeout, @@ -1322,6 +1323,76 @@ def test_retry_uses_real_sdk_overload_classification(self, codex_sdk, tmp_path): assert result["_retry_info"]["retries"] == 1 assert client.thread_start.call_count == 2 + # O `is_retryable_error` do SDK reconhece só `server_overloaded`; o código de + # rate limit no `data` do erro JSON-RPC é lido como no turno que falha, e o + # "429" da mensagem não decide nada. + def test_rpc_rate_limit_code_is_overload_and_reduces_workers(self, codex_sdk, tmp_path): + sdk, _, _ = codex_sdk + backend, client, _, _ = initialized_backend(tmp_path, codex_sdk) + client.thread_start.side_effect = lambda **_: (_ for _ in ()).throw( + sdk.CodexRpcError( + -32000, "429 Too Many Requests", {"codex_error_info": "rate_limit_exceeded"} + ) + ) + + with ( + pytest.warns(UserWarning, match="Tentativa 1/2"), + pytest.raises(ProviderOverloadedError, match="429 Too Many Requests") as error, + ): + backend.invoke("texto") + + assert client.thread_start.call_count == 2 + assert is_rate_limit_error(error.value) is True + + @pytest.mark.parametrize( + "data", + [ + {"codex_error_info": "rate_limit_exceeded"}, + {"codexErrorInfo": "rateLimitExceeded"}, + {"errorInfo": "flex_unavailable"}, + {"codexErrorInfo": {"httpConnectionFailed": {"httpStatusCode": 429}}}, + {"error": {"codexErrorInfo": "rateLimitExceeded"}}, + [{"codexErrorInfo": "serverOverloaded"}], + ], + ) + def test_rpc_overload_code_in_data_is_overload(self, codex_sdk, data): + sdk, _, _ = codex_sdk + + with pytest.raises(ProviderOverloadedError): + _raise_classified_sdk_error(sdk.CodexRpcError(-32000, "falhou", data)) + + @pytest.mark.parametrize( + "data", + [ + None, + "rateLimitExceeded", + {"message": "rate_limit_exceeded"}, + {"codexErrorInfo": "badRequest"}, + {"codexErrorInfo": "codigo_desconhecido"}, + {"codexErrorInfo": {"httpConnectionFailed": {"httpStatusCode": 401}}}, + [{"codexErrorInfo": "unauthorized"}], + ], + ) + def test_rpc_error_without_overload_code_is_definitive(self, codex_sdk, data): + sdk, _, _ = codex_sdk + + with pytest.raises(ProviderError) as error: + _raise_classified_sdk_error(sdk.CodexRpcError(-32000, "429 Too Many Requests", data)) + + assert not isinstance(error.value, ProviderTransientError) + assert is_rate_limit_error(error.value) is False + + def test_non_rpc_error_data_is_not_read(self, codex_sdk): + class ErrorWithData(RuntimeError): + def __init__(self, data: object) -> None: + super().__init__("falhou") + self.data = data + + with pytest.raises(ProviderError) as raised: + _raise_classified_sdk_error(ErrorWithData({"codexErrorInfo": "rateLimitExceeded"})) + + assert not isinstance(raised.value, ProviderTransientError) + def test_failed_turn_overload_uses_real_protocol_error(self, codex_sdk, tmp_path): _, _, generated = codex_sdk backend, client, thread, turn = initialized_backend(tmp_path, codex_sdk) From fbd05cd930c60b6095e30cf615c913763267e47a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 10:55:35 -0300 Subject: [PATCH 09/11] =?UTF-8?q?Documenta=C3=A7=C3=A3o=20diz=20quando=20o?= =?UTF-8?q?s=20workers=20caem=20por=20rate=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FAQ, guia de performance, nas duas línguas, e a docstring de dataframeit() diziam que os workers caem a cada 429. Eles caem quando o provider identifica o rate limit pelo status ou pelo código do erro. --- docs/en/guides/faq.md | 2 +- docs/en/guides/performance.md | 2 +- docs/guides/faq.md | 2 +- docs/guides/performance.md | 2 +- src/dataframeit/core.py | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index 3d68233a..a22ef074 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -53,7 +53,7 @@ With `resume=False`, if the model columns are already in the DataFrame and `repr ### Many 429 errors (rate limit) -The provider rejected requests for exceeding its rate. dataframeit already halves the workers on each 429 and retries with backoff, but the stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). +The provider rejected requests for exceeding its rate. dataframeit retries with backoff and halves the workers when the provider identifies the rate limit by the status or the error code, but the stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). ### How much will it cost? diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index ba42d9bb..a7ca6753 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -25,7 +25,7 @@ result = dataframeit( ### Auto-reduction on Rate Limits -When a 429 error is detected, DataFrameIt automatically reduces workers: +When the provider identifies a rate limit by the status or the error code, DataFrameIt automatically reduces workers: ``` Start: 10 workers diff --git a/docs/guides/faq.md b/docs/guides/faq.md index 946210a0..74bfae07 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -53,7 +53,7 @@ Com `resume=False`, se as colunas do modelo já estão no DataFrame e `reprocess ### Muitos erros 429 (rate limit) -O provider recusou requisições por excesso de taxa. O dataframeit já reduz os workers pela metade a cada 429 e tenta de novo com backoff, mas a solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). +O provider recusou requisições por excesso de taxa. O dataframeit tenta de novo com backoff e reduz os workers pela metade quando o provider identifica o rate limit pelo status ou pelo código do erro, mas a solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). ### Quanto vai custar? diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 61c98e57..775da3da 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -25,7 +25,7 @@ resultado = dataframeit( ### Auto-redução em Rate Limits -Quando detecta erro 429, o DataFrameIt reduz workers automaticamente: +Quando o provider identifica rate limit pelo status ou pelo código do erro, o DataFrameIt reduz os workers automaticamente: ``` Início: 10 workers diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index 728a09b8..677ab0b4 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1077,7 +1077,8 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública Com provider='codex', aceita somente effort. parallel_requests: Número de requisições paralelas (padrão: 1 = sequencial). Se > 1, processa múltiplas linhas simultaneamente. - Ao detectar erro de rate limit (429), o número de workers é reduzido automaticamente. + Quando o provider identifica rate limit pelo status ou pelo código do erro, o + número de workers cai pela metade. Dica: use track_tokens=True para ver métricas de throughput (RPM, TPM) e calibrar. use_search: Se True, habilita busca web antes de processar. Padrão: False. search_provider: Provedor de busca web a usar. Opções: From 92cbe791bbd35d58c9688efbffcbee5d748a10c5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 16:28:16 -0300 Subject: [PATCH 10/11] Erro -32001 do app-server do codex levanta ProviderOverloadedError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O app-server 0.160.1 recusa com o código JSON-RPC -32001, mensagem "Server overloaded; retry later." e data nulo, o pedido que chega com a fila de entrada cheia. O is_retryable_error do SDK procura a sobrecarga só no data, e esse erro saía ProviderError definitivo: a linha falhava na primeira tentativa e os workers não caíam. _raise_classified_sdk_error passa a levantar ProviderOverloadedError pelo código, sem ler a mensagem. Sai a leitura de códigos de rate limit no data do erro JSON-RPC, que nenhum erro do app-server envia, com os helpers que só ela usava; _raise_turn_error volta ao que era antes dela. Os testes da falha HTTP sem status ou 5xx e dos códigos transitórios do turno conferem que o erro não conta como rate limit. A documentação diz que os workers caem quando a linha falha depois de todas as tentativas, que o codex reconhece o rate limit pelo código ou pelo status do erro e que os providers do LangChain também leem o texto da mensagem quando o erro não traz status HTTP. --- CHANGELOG.md | 2 +- docs/en/guides/faq.md | 2 +- docs/en/guides/performance.md | 4 +- docs/guides/faq.md | 2 +- docs/guides/performance.md | 4 +- src/dataframeit/codex.py | 124 ++++++--------------- src/dataframeit/core.py | 6 +- src/dataframeit/errors.py | 6 +- tests/test_codex.py | 198 +++++++++++++++++++--------------- 9 files changed, 158 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e78519..39450b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido -- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para todo `ProviderError` e para o `ProviderOutputError`, e entre eles só o `ProviderOverloadedError` conta como rate limit: um '429' no texto, como no resumo de um erro de validação do `codex` ou no campo `result` que o SDK do Claude Code devolve num erro, deixa de reduzir os workers. No `codex`, o erro JSON-RPC que traz no `data` um código de rate limit, como `rate_limit_exceeded`, levanta `ProviderOverloadedError`, como o turno que falha com o mesmo código, e a linha é repetida; antes, o SDK só reconhecia `server_overloaded`, e o erro falhava a linha sem nova tentativa. O erro do `codex`, de turno ou JSON-RPC, que traz '429 Too Many Requests' só no texto, sem código classificável, deixa de reduzir os workers (#192). +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para todo `ProviderError` e para o `ProviderOutputError`, e entre eles só o `ProviderOverloadedError` conta como rate limit: um '429' no texto, como no resumo de um erro de validação do `codex` ou no campo `result` que o SDK do Claude Code devolve num erro, deixa de reduzir os workers. No `codex`, o erro JSON-RPC de código -32001, com que o app-server recusa o pedido quando a fila de entrada está cheia, levanta `ProviderOverloadedError`: a linha é repetida e, se falhar em todas as tentativas, reduz os workers, como no HTTP 429. Antes, esse erro falhava a linha na primeira tentativa, sem reduzir os workers. O erro do `codex`, de turno ou JSON-RPC, que traz '429 Too Many Requests' só no texto deixa de reduzir os workers, porque o rate limit é reconhecido pelo código do erro (#192). - A retomada com `resume=True` de uma saída polars deixa de recusar a linha processada quando o campo do modelo guarda datas numa lista, como `list[date]`, `list[datetime]` com ou sem fuso, `tuple[date, date]` e o submodelo com `list[date]`, ou inteiros ao lado de nulo, como `list[Optional[int]]`. A conversão da entrada polars para pandas entregava essa lista como array do numpy, com a data em `datetime64`, o datetime com fuso passado a UTC sem o fuso e o inteiro ao lado de nulo em float, arredondado acima de `2**53`. As colunas `List`, `Array` e `Struct` passam a ser convertidas pelo `to_list` do polars, e a linha retomada sai com os valores e o tipo de coluna da execução sem interrupção, inclusive o fuso. Com isso, a coluna de texto do prompt que é `List` ou `Array`, ou `Struct` com lista dentro, chega ao modelo com a lista do Python, como `['a', 'b']` no lugar de `['a' 'b']`, e um checkpoint gravado antes com essa coluna de texto é descartado com aviso e a execução recomeça do zero (#224). - Com entrada polars, um campo `Decimal` com valor acima de 38 dígitos deixa de derrubar a saída depois de todas as chamadas com o `PanicException` do polars, que não é `Exception`. A coluna sai `Object`, com aviso, como as demais que o polars recusa (#230). - Com entrada polars, a saída deixa de falhar depois de todas as chamadas quando um campo do modelo guarda valores que o polars não reúne numa coluna tipada, como `Union[int, str]` com número numa linha e texto em outra, `tuple[int, str]`, `Any` com tipos diferentes, lista de `Enum`, inteiro acima de 2**53 ao lado de float, inteiro acima do `Int64` e `datetime` com fuso de deslocamento, como `+05:30`. A coluna sai com o tipo `Object`, com os objetos Python que a entrada pandas devolveria, e um aviso a nomeia. O campo `Enum` sai pelo valor do membro, inclusive na coluna `Object` (#218). diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index a22ef074..a845d263 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -53,7 +53,7 @@ With `resume=False`, if the model columns are already in the DataFrame and `repr ### Many 429 errors (rate limit) -The provider rejected requests for exceeding its rate. dataframeit retries with backoff and halves the workers when the provider identifies the rate limit by the status or the error code, but the stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). +The provider rejected requests for exceeding its rate. dataframeit retries with backoff and, when the row fails after all attempts, halves the workers. With `codex`, the rate limit is recognized by the error code or status, without reading the message; with the LangChain providers, also by the message text, when the error carries no HTTP status. The stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). ### How much will it cost? diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index a7ca6753..84f447a6 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -25,7 +25,7 @@ result = dataframeit( ### Auto-reduction on Rate Limits -When the provider identifies a rate limit by the status or the error code, DataFrameIt automatically reduces workers: +When a row fails after all attempts with a rate limit or overload error, DataFrameIt halves the workers: ``` Start: 10 workers @@ -34,6 +34,8 @@ Rate limit detected → 2 workers Rate limit detected → 1 worker ``` +With `codex`, the rate limit is recognized by the error code or status, without reading the message. With the LangChain providers, also by the message text, when the error carries no HTTP status. + !!! info "Safety" Workers are only **reduced**, never automatically increased. This prevents unexpected costs. diff --git a/docs/guides/faq.md b/docs/guides/faq.md index 74bfae07..2c4bd5d4 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -53,7 +53,7 @@ Com `resume=False`, se as colunas do modelo já estão no DataFrame e `reprocess ### Muitos erros 429 (rate limit) -O provider recusou requisições por excesso de taxa. O dataframeit tenta de novo com backoff e reduz os workers pela metade quando o provider identifica o rate limit pelo status ou pelo código do erro, mas a solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). +O provider recusou requisições por excesso de taxa. O dataframeit tenta de novo com backoff e, quando a linha falha depois de todas as tentativas, reduz os workers pela metade. No `codex`, o rate limit é reconhecido pelo código ou pelo status do erro, sem ler a mensagem; nos providers do LangChain, também pelo texto da mensagem, quando o erro não traz status HTTP. A solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). ### Quanto vai custar? diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 775da3da..23794013 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -25,7 +25,7 @@ resultado = dataframeit( ### Auto-redução em Rate Limits -Quando o provider identifica rate limit pelo status ou pelo código do erro, o DataFrameIt reduz os workers automaticamente: +Quando uma linha falha depois de todas as tentativas com erro de rate limit ou de sobrecarga, o DataFrameIt reduz os workers pela metade: ``` Início: 10 workers @@ -34,6 +34,8 @@ Rate limit detectado → 2 workers Rate limit detectado → 1 worker ``` +No `codex`, o rate limit é reconhecido pelo código ou pelo status do erro, sem ler a mensagem. Nos providers do LangChain, também pelo texto da mensagem, quando o erro não traz status HTTP. + !!! info "Segurança" Workers são apenas **reduzidos**, nunca aumentados automaticamente. Isso evita custos inesperados. diff --git a/src/dataframeit/codex.py b/src/dataframeit/codex.py index 4e664bc8..b490d491 100644 --- a/src/dataframeit/codex.py +++ b/src/dataframeit/codex.py @@ -43,6 +43,11 @@ # Status HTTP de rate limit e início da faixa de erro do servidor. _HTTP_TOO_MANY_REQUESTS = 429 _HTTP_SERVER_ERROR_MIN = 500 +# Código JSON-RPC com que o app-server do Codex recusa um pedido quando a fila de +# entrada está cheia, com a mensagem "Server overloaded; retry later." e sem +# `data`. O `is_retryable_error` do SDK procura a sobrecarga só no `data` e não o +# reconhece. +_RPC_SERVER_OVERLOADED = -32001 _ALLOWED_MODEL_KWARGS = frozenset({"effort", "timeout"}) _AUTH_LOCK_SUFFIX = ".dataframeit.lock" @@ -901,6 +906,10 @@ def _raise_turn_error(error: TurnError | None) -> NoReturn: """Classifica o turno que falhou pelo `codex_error_info` que o runtime enviou.""" from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) CodexErrorInfoValue, + HttpConnectionFailedCodexErrorInfo, + ResponseStreamConnectionFailedCodexErrorInfo, + ResponseStreamDisconnectedCodexErrorInfo, + ResponseTooManyFailedAttemptsCodexErrorInfo, ) if error is None: @@ -911,37 +920,21 @@ def _raise_turn_error(error: TurnError | None) -> NoReturn: root = getattr(error.codex_error_info, "root", None) if root is CodexErrorInfoValue.usage_limit_exceeded: raise ProviderUsageLimitError(message) - if _is_overload_info(root): + + # Sobrecarga, limite de requisições e falta de capacidade do tier flex passam + # com o tempo: a linha é repetida com backoff, como no HTTP 429. + overload_codes = { + CodexErrorInfoValue.server_overloaded, + CodexErrorInfoValue.rate_limit_exceeded, + CodexErrorInfoValue.flex_unavailable, + } + if isinstance(root, CodexErrorInfoValue) and root in overload_codes: raise ProviderOverloadedError(message) transient_codes = { CodexErrorInfoValue.internal_server_error, CodexErrorInfoValue.thread_rollback_failed, } - is_http, status = _http_variant_status(root) - if is_http: - if status is None or status >= _HTTP_SERVER_ERROR_MIN: - raise ProviderTransientError(message) - raise ProviderError(message) - - if isinstance(root, CodexErrorInfoValue) and root in transient_codes: - raise ProviderTransientError(message) - - # Os demais códigos ficam como falha da linha. Entre eles, a violação de - # política vem do conteúdo da requisição, e as recusas acumuladas e o - # orçamento da sessão contam por thread, que é própria de cada linha. - raise ProviderError(message) - - -def _http_variant_status(root: object) -> tuple[bool, int | None]: - """Diz se o `codex_error_info` é variante HTTP e devolve o status dela, que pode faltar.""" - from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) - HttpConnectionFailedCodexErrorInfo, - ResponseStreamConnectionFailedCodexErrorInfo, - ResponseStreamDisconnectedCodexErrorInfo, - ResponseTooManyFailedAttemptsCodexErrorInfo, - ) - http_variants = ( (HttpConnectionFailedCodexErrorInfo, "http_connection_failed"), ( @@ -958,73 +951,22 @@ def _http_variant_status(root: object) -> tuple[bool, int | None]: ), ) for variant_type, payload_field in http_variants: - if isinstance(root, variant_type): - return True, getattr(root, payload_field).http_status_code - return False, None - - -def _is_overload_info(root: object) -> bool: - """Diz se o `codex_error_info` é de sobrecarga ou de rate limit. - - Sobrecarga, limite de requisições, falta de capacidade do tier flex e o - HTTP 429 de uma variante HTTP passam com o tempo: a linha é repetida com - backoff, e os workers caem. - """ - from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) - CodexErrorInfoValue, - ) - - if isinstance(root, CodexErrorInfoValue): - return root in { - CodexErrorInfoValue.server_overloaded, - CodexErrorInfoValue.rate_limit_exceeded, - CodexErrorInfoValue.flex_unavailable, - } - return _http_variant_status(root) == (True, _HTTP_TOO_MANY_REQUESTS) - - -# Chaves em que o `is_retryable_error` do SDK procura o `codex_error_info` no -# `data` de um erro JSON-RPC. -_RPC_ERROR_INFO_KEYS = ("codex_error_info", "codexErrorInfo", "errorInfo") - - -def _parse_error_info(value: object) -> object: - """Converte o `codex_error_info` de um erro JSON-RPC na raiz do `CodexErrorInfo`. - - Aceita o código nas duas grafias: camelCase, a do protocolo v2, que o - `CodexErrorInfo` valida, e snake_case, a que o SDK procura. Devolve `None` - quando o valor não é um código conhecido. - """ - from openai_codex.generated.v2_all import ( # noqa: PLC0415 (extra codex opcional) - CodexErrorInfo, - CodexErrorInfoValue, - ) - - if isinstance(value, str) and value in CodexErrorInfoValue.__members__: - return CodexErrorInfoValue[value] - try: - return CodexErrorInfo.model_validate(value).root - except ValidationError: - return None - + if not isinstance(root, variant_type): + continue + status = getattr(root, payload_field).http_status_code + if status == _HTTP_TOO_MANY_REQUESTS: + raise ProviderOverloadedError(message) + if status is None or status >= _HTTP_SERVER_ERROR_MIN: + raise ProviderTransientError(message) + raise ProviderError(message) -def _rpc_error_is_overload(data: object) -> bool: - """Diz se o `data` de um erro JSON-RPC traz código de sobrecarga ou de rate limit. + if isinstance(root, CodexErrorInfoValue) and root in transient_codes: + raise ProviderTransientError(message) - Procura onde o `is_retryable_error` do SDK procura: nas chaves de - `_RPC_ERROR_INFO_KEYS` do `data` e de cada dict dentro dele, inclusive em - listas. O SDK reconhece só `server_overloaded`, e um `rate_limit_exceeded` - sairia como erro definitivo; aqui vale a regra de `_is_overload_info`, a - mesma do turno que falha. A mensagem do erro não é lida. - """ - if isinstance(data, list): - return any(_rpc_error_is_overload(item) for item in data) - if not isinstance(data, dict): - return False - for key in _RPC_ERROR_INFO_KEYS: - if key in data and _is_overload_info(_parse_error_info(data[key])): - return True - return any(_rpc_error_is_overload(value) for value in data.values()) + # Os demais códigos ficam como falha da linha. Entre eles, a violação de + # política vem do conteúdo da requisição, e as recusas acumuladas e o + # orçamento da sessão contam por thread, que é própria de cada linha. + raise ProviderError(message) def _raise_classified_sdk_error(error: Exception) -> NoReturn: @@ -1040,7 +982,7 @@ def _raise_classified_sdk_error(error: Exception) -> NoReturn: msg = f"O app-server do Codex encerrou: {message}" raise ProviderAbortError(msg) from error if is_retryable_error(error) or ( - isinstance(error, JsonRpcError) and _rpc_error_is_overload(error.data) + isinstance(error, JsonRpcError) and error.code == _RPC_SERVER_OVERLOADED ): raise ProviderOverloadedError(message) from error raise ProviderError(message) from error diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index 677ab0b4..29068689 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1077,8 +1077,10 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública Com provider='codex', aceita somente effort. parallel_requests: Número de requisições paralelas (padrão: 1 = sequencial). Se > 1, processa múltiplas linhas simultaneamente. - Quando o provider identifica rate limit pelo status ou pelo código do erro, o - número de workers cai pela metade. + Quando uma linha falha depois de todas as tentativas com erro de rate limit + ou de sobrecarga, o número de workers cai pela metade. No codex, o erro é + reconhecido pelo código ou pelo status, sem ler a mensagem; nos providers do + LangChain, também pelo texto da mensagem, quando o erro não traz status HTTP. Dica: use track_tokens=True para ver métricas de throughput (RPM, TPM) e calibrar. use_search: Se True, habilita busca web antes de processar. Padrão: False. search_provider: Provedor de busca web a usar. Opções: diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index 5559a344..d613a7fe 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -828,9 +828,9 @@ def is_rate_limit_error(error: Exception) -> bool: # o ProviderOutputError decidem pela classe, sem ler o texto: a mensagem deles # pode trazer texto do modelo, como o resumo da validação e o campo result do # SDK no erro do claude_code, e um '429' ali seria tomado por rate limit. O - # codex classifica o rate limit pelo código do turno ou do erro JSON-RPC, e o - # erro que traz "429 Too Many Requests" só no texto, sem código - # classificável, não reduz os workers. + # codex classifica o rate limit na origem, pelo código de erro do turno ou do + # JSON-RPC, e o erro que traz "429 Too Many Requests" só no texto não reduz os + # workers. if isinstance(error, (ProviderError, ProviderOutputError)): return False if _ModelRateLimitError is not None and isinstance(error, _ModelRateLimitError): diff --git a/tests/test_codex.py b/tests/test_codex.py index c18195e5..572b7716 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import dataclasses import json import os @@ -14,10 +15,12 @@ from datetime import time as time_type from ipaddress import IPv4Address, IPv6Address from pathlib import Path +from types import SimpleNamespace from typing import Annotated, Any, Literal from unittest.mock import MagicMock, call, patch from uuid import UUID +import pandas as pd import pytest from pydantic import UUID4, AnyUrl, BaseModel, ConfigDict, Field, RootModel from pydantic.errors import PydanticInvalidForJsonSchema @@ -34,6 +37,7 @@ _validate_config, open_codex_backend, ) +from dataframeit.core import dataframeit from dataframeit.errors import ( CODEX_FILE_AUTH_LOGIN_COMMAND, ProviderAbortError, @@ -1323,76 +1327,6 @@ def test_retry_uses_real_sdk_overload_classification(self, codex_sdk, tmp_path): assert result["_retry_info"]["retries"] == 1 assert client.thread_start.call_count == 2 - # O `is_retryable_error` do SDK reconhece só `server_overloaded`; o código de - # rate limit no `data` do erro JSON-RPC é lido como no turno que falha, e o - # "429" da mensagem não decide nada. - def test_rpc_rate_limit_code_is_overload_and_reduces_workers(self, codex_sdk, tmp_path): - sdk, _, _ = codex_sdk - backend, client, _, _ = initialized_backend(tmp_path, codex_sdk) - client.thread_start.side_effect = lambda **_: (_ for _ in ()).throw( - sdk.CodexRpcError( - -32000, "429 Too Many Requests", {"codex_error_info": "rate_limit_exceeded"} - ) - ) - - with ( - pytest.warns(UserWarning, match="Tentativa 1/2"), - pytest.raises(ProviderOverloadedError, match="429 Too Many Requests") as error, - ): - backend.invoke("texto") - - assert client.thread_start.call_count == 2 - assert is_rate_limit_error(error.value) is True - - @pytest.mark.parametrize( - "data", - [ - {"codex_error_info": "rate_limit_exceeded"}, - {"codexErrorInfo": "rateLimitExceeded"}, - {"errorInfo": "flex_unavailable"}, - {"codexErrorInfo": {"httpConnectionFailed": {"httpStatusCode": 429}}}, - {"error": {"codexErrorInfo": "rateLimitExceeded"}}, - [{"codexErrorInfo": "serverOverloaded"}], - ], - ) - def test_rpc_overload_code_in_data_is_overload(self, codex_sdk, data): - sdk, _, _ = codex_sdk - - with pytest.raises(ProviderOverloadedError): - _raise_classified_sdk_error(sdk.CodexRpcError(-32000, "falhou", data)) - - @pytest.mark.parametrize( - "data", - [ - None, - "rateLimitExceeded", - {"message": "rate_limit_exceeded"}, - {"codexErrorInfo": "badRequest"}, - {"codexErrorInfo": "codigo_desconhecido"}, - {"codexErrorInfo": {"httpConnectionFailed": {"httpStatusCode": 401}}}, - [{"codexErrorInfo": "unauthorized"}], - ], - ) - def test_rpc_error_without_overload_code_is_definitive(self, codex_sdk, data): - sdk, _, _ = codex_sdk - - with pytest.raises(ProviderError) as error: - _raise_classified_sdk_error(sdk.CodexRpcError(-32000, "429 Too Many Requests", data)) - - assert not isinstance(error.value, ProviderTransientError) - assert is_rate_limit_error(error.value) is False - - def test_non_rpc_error_data_is_not_read(self, codex_sdk): - class ErrorWithData(RuntimeError): - def __init__(self, data: object) -> None: - super().__init__("falhou") - self.data = data - - with pytest.raises(ProviderError) as raised: - _raise_classified_sdk_error(ErrorWithData({"codexErrorInfo": "rateLimitExceeded"})) - - assert not isinstance(raised.value, ProviderTransientError) - def test_failed_turn_overload_uses_real_protocol_error(self, codex_sdk, tmp_path): _, _, generated = codex_sdk backend, client, thread, turn = initialized_backend(tmp_path, codex_sdk) @@ -1409,24 +1343,23 @@ def test_failed_turn_overload_uses_real_protocol_error(self, codex_sdk, tmp_path assert client.thread_start.call_count == 2 thread.read.assert_not_called() - def test_failed_turn_internal_server_error_retries_without_rate_limit( - self, codex_sdk, tmp_path - ): + @pytest.mark.parametrize("code", ["internal_server_error", "thread_rollback_failed"]) + def test_failed_turn_transient_code_retries_without_rate_limit(self, codex_sdk, tmp_path, code): _, _, generated = codex_sdk backend, client, _, turn = initialized_backend(tmp_path, codex_sdk) - internal = generated.CodexErrorInfo( - root=generated.CodexErrorInfoValue.internal_server_error + info = generated.CodexErrorInfo(root=generated.CodexErrorInfoValue[code]) + turn.stream.side_effect = lambda: as_stream( + make_result(codex_sdk, error_info=info, message="internal failure") ) - turn.stream.side_effect = [ - as_stream(make_result(codex_sdk, error_info=internal, message="internal failure")), - as_stream(make_result(codex_sdk)), - ] - with pytest.warns(UserWarning, match="Tentativa 1/2"): - result = backend.invoke("texto") + with ( + pytest.warns(UserWarning, match="Tentativa 1/2"), + pytest.raises(ProviderTransientError, match="internal failure") as exc_info, + ): + backend.invoke("texto") - assert result["_retry_info"]["retries"] == 1 assert client.thread_start.call_count == 2 + assert is_rate_limit_error(exc_info.value) is False def test_failed_turn_http_429_is_overload_and_retries(self, codex_sdk, tmp_path): _, _, generated = codex_sdk @@ -1720,16 +1653,18 @@ def test_falha_http_sem_status_ou_do_servidor_e_transitoria(self, codex_sdk, tmp ) ) ) - turn.stream.side_effect = [ - as_stream(make_result(codex_sdk, error_info=caiu, message="stream caiu")), - as_stream(make_result(codex_sdk)), - ] + turn.stream.side_effect = lambda: as_stream( + make_result(codex_sdk, error_info=caiu, message="stream caiu") + ) - with pytest.warns(UserWarning, match="Tentativa 1/2"): - result = backend.invoke("texto") + with ( + pytest.warns(UserWarning, match="Tentativa 1/2"), + pytest.raises(ProviderTransientError, match="stream caiu") as exc_info, + ): + backend.invoke("texto") - assert result["_retry_info"]["retries"] == 1 assert client.thread_start.call_count == 2 + assert is_rate_limit_error(exc_info.value) is False def test_codigo_de_erro_fora_dos_transitorios_e_definitivo(self, codex_sdk, tmp_path): _, _, generated = codex_sdk @@ -1797,6 +1732,91 @@ def test_app_server_encerrado_interrompe_a_execucao(self, codex_sdk, tmp_path, o assert client.thread_start.call_count == 1 + # O app-server recusa com o código JSON-RPC -32001, sem `data`, o pedido que + # chega com a fila de entrada cheia. A classificação lê o código, e não a + # mensagem: o -32000 com o mesmo texto continua definitivo. + def test_fila_cheia_do_app_server_e_sobrecarga(self, codex_sdk): + from openai_codex.errors import ( # noqa: PLC0415 (SDK carregado pelo fixture) + map_jsonrpc_error, + ) + + erro = map_jsonrpc_error(-32001, "Server overloaded; retry later.", None) + + with pytest.raises(ProviderOverloadedError, match="Server overloaded") as exc_info: + _raise_classified_sdk_error(erro) + + assert is_rate_limit_error(exc_info.value) is True + + @pytest.mark.parametrize("codigo", [-32000, -32002]) + def test_outro_codigo_json_rpc_com_texto_de_sobrecarga_e_definitivo(self, codex_sdk, codigo): + from openai_codex.errors import ( # noqa: PLC0415 (SDK carregado pelo fixture) + map_jsonrpc_error, + ) + + erro = map_jsonrpc_error(codigo, "Server overloaded; retry later.", None) + + with pytest.raises(ProviderError) as exc_info: + _raise_classified_sdk_error(erro) + + assert not isinstance(exc_info.value, ProviderTransientError) + assert is_rate_limit_error(exc_info.value) is False + + def test_codigo_fora_de_erro_json_rpc_nao_e_lido(self, codex_sdk): + class ErroComCodigo(RuntimeError): + code = -32001 + + with pytest.raises(ProviderError) as exc_info: + _raise_classified_sdk_error(ErroComCodigo("falhou")) + + assert not isinstance(exc_info.value, ProviderTransientError) + + def test_fila_cheia_reduz_workers_e_repete_a_linha(self, codex_sdk, tmp_path, monkeypatch): + from openai_codex.errors import ( # noqa: PLC0415 (SDK carregado pelo fixture) + map_jsonrpc_error, + ) + + backend, client, _, _ = initialized_backend(tmp_path, codex_sdk) + pedidos = [] + trava = threading.Lock() + + def fila_cheia(**_): + with trava: + pedidos.append(1) + raise map_jsonrpc_error(-32001, "Server overloaded; retry later.", None) + + client.thread_start.side_effect = fila_cheia + + @contextlib.contextmanager + def backend_falso(*_args): + yield backend + + monkeypatch.setattr("dataframeit.codex.open_codex_backend", backend_falso) + # O evento de rate limit é limpo na hora, sem o timer de 5 s. + monkeypatch.setattr( + "dataframeit.core.threading.Timer", + lambda _intervalo, funcao: SimpleNamespace(start=funcao), + ) + entrada = pd.DataFrame({"texto": ["a", "b", "c", "d"]}) + + with pytest.warns(UserWarning, match="Reduzindo workers de 4 para 2") as avisos: + saida = dataframeit( + entrada, SampleModel, "Analise: {texto}", provider="codex", parallel_requests=4 + ) + + mensagens = [str(aviso.message) for aviso in avisos] + assert [m for m in mensagens if m.startswith("Rate limit detectado")] == [ + "Rate limit detectado! Reduzindo workers de 4 para 2.", + "Rate limit detectado! Reduzindo workers de 2 para 1.", + ] + # make_config dá duas tentativas a cada linha. + assert ( + sum(m.startswith("Tentativa 1/2 falhou (ProviderOverloadedError)") for m in mensagens) + == 4 + ) + assert len(pedidos) == 8 + assert saida["_dataframeit_status"].tolist() == ["error"] * 4 + assert all("ProviderOverloadedError" in detalhe for detalhe in saida["_error_details"]) + def test_stream_sem_conclusao_e_transitorio(self, codex_sdk, tmp_path): backend, client, _, turn = initialized_backend(tmp_path, codex_sdk) sem_conclusao = make_result(codex_sdk)[:-1] From 2fa6d671a47093a7b4b0a55bd0b6e56cbd80c171 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 17:06:08 -0300 Subject: [PATCH 11/11] =?UTF-8?q?Documenta=C3=A7=C3=A3o=20da=20redu=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20workers=20diz=20que=20a=20sobrecarga=20s=C3=B3?= =?UTF-8?q?=20conta=20no=20codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/guides/faq.md | 2 +- docs/en/guides/performance.md | 4 ++-- docs/guides/faq.md | 2 +- docs/guides/performance.md | 4 ++-- src/dataframeit/core.py | 9 +++++---- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index a845d263..05e5e743 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -53,7 +53,7 @@ With `resume=False`, if the model columns are already in the DataFrame and `repr ### Many 429 errors (rate limit) -The provider rejected requests for exceeding its rate. dataframeit retries with backoff and, when the row fails after all attempts, halves the workers. With `codex`, the rate limit is recognized by the error code or status, without reading the message; with the LangChain providers, also by the message text, when the error carries no HTTP status. The stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). +The provider rejected requests for exceeding its rate. dataframeit retries with backoff and, when the row fails after all attempts, halves the workers. With `codex`, the rate limit and the app-server overload are recognized by the error code or status, without reading the message; with the LangChain providers, the rate limit is recognized by the 429 status or the error class and, when the error carries neither, by the message text. The stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). ### How much will it cost? diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index 84f447a6..4c0b040d 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -25,7 +25,7 @@ result = dataframeit( ### Auto-reduction on Rate Limits -When a row fails after all attempts with a rate limit or overload error, DataFrameIt halves the workers: +When a row fails after all attempts with a rate limit error, DataFrameIt halves the workers: ``` Start: 10 workers @@ -34,7 +34,7 @@ Rate limit detected → 2 workers Rate limit detected → 1 worker ``` -With `codex`, the rate limit is recognized by the error code or status, without reading the message. With the LangChain providers, also by the message text, when the error carries no HTTP status. +With `codex`, the rate limit and the app-server overload are recognized by the error code or status, without reading the message. With the LangChain providers, the rate limit is recognized by the 429 status or the error class and, when the error carries neither, by the message text; overload (529, 503) does not reduce the workers. !!! info "Safety" Workers are only **reduced**, never automatically increased. This prevents unexpected costs. diff --git a/docs/guides/faq.md b/docs/guides/faq.md index 2c4bd5d4..a1c7fbfd 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -53,7 +53,7 @@ Com `resume=False`, se as colunas do modelo já estão no DataFrame e `reprocess ### Muitos erros 429 (rate limit) -O provider recusou requisições por excesso de taxa. O dataframeit tenta de novo com backoff e, quando a linha falha depois de todas as tentativas, reduz os workers pela metade. No `codex`, o rate limit é reconhecido pelo código ou pelo status do erro, sem ler a mensagem; nos providers do LangChain, também pelo texto da mensagem, quando o erro não traz status HTTP. A solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). +O provider recusou requisições por excesso de taxa. O dataframeit tenta de novo com backoff e, quando a linha falha depois de todas as tentativas, reduz os workers pela metade. No `codex`, o rate limit e a sobrecarga do app-server são reconhecidos pelo código ou pelo status do erro, sem ler a mensagem; nos providers do LangChain, o rate limit é reconhecido pelo status 429 ou pela classe do erro e, quando o erro não traz nenhum dos dois, pelo texto da mensagem. A solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). ### Quanto vai custar? diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 23794013..fb0b129d 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -25,7 +25,7 @@ resultado = dataframeit( ### Auto-redução em Rate Limits -Quando uma linha falha depois de todas as tentativas com erro de rate limit ou de sobrecarga, o DataFrameIt reduz os workers pela metade: +Quando uma linha falha depois de todas as tentativas com erro de rate limit, o DataFrameIt reduz os workers pela metade: ``` Início: 10 workers @@ -34,7 +34,7 @@ Rate limit detectado → 2 workers Rate limit detectado → 1 worker ``` -No `codex`, o rate limit é reconhecido pelo código ou pelo status do erro, sem ler a mensagem. Nos providers do LangChain, também pelo texto da mensagem, quando o erro não traz status HTTP. +No `codex`, o rate limit e a sobrecarga do app-server são reconhecidos pelo código ou pelo status do erro, sem ler a mensagem. Nos providers do LangChain, o rate limit é reconhecido pelo status 429 ou pela classe do erro e, quando o erro não traz nenhum dos dois, pelo texto da mensagem; a sobrecarga (529, 503) não reduz os workers. !!! info "Segurança" Workers são apenas **reduzidos**, nunca aumentados automaticamente. Isso evita custos inesperados. diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index 3e35f17c..cb0b19fa 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1077,10 +1077,11 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública Com provider='codex', aceita somente effort. parallel_requests: Número de requisições paralelas (padrão: 1 = sequencial). Se > 1, processa múltiplas linhas simultaneamente. - Quando uma linha falha depois de todas as tentativas com erro de rate limit - ou de sobrecarga, o número de workers cai pela metade. No codex, o erro é - reconhecido pelo código ou pelo status, sem ler a mensagem; nos providers do - LangChain, também pelo texto da mensagem, quando o erro não traz status HTTP. + Quando uma linha falha depois de todas as tentativas com erro de rate limit, + o número de workers cai pela metade. No codex, o rate limit e a sobrecarga do + app-server são reconhecidos pelo código ou pelo status, sem ler a mensagem; + nos providers do LangChain, o rate limit é reconhecido pelo status 429 ou pela + classe do erro e, sem nenhum dos dois, pelo texto da mensagem. Dica: use track_tokens=True para ver métricas de throughput (RPM, TPM) e calibrar. use_search: Se True, habilita busca web antes de processar. Padrão: False. search_provider: Provedor de busca web a usar. Opções: