Repository navigation
fix(backend): persist token revocation, typed provider errors, strict retry, safer defaults - #155
Merged
Merged
Conversation
OpenAI bills gpt-5.4-mini at $0.75 input, $0.075 cached input and $4.50 output per 1M tokens; the old $0.15/$0.60 table recorded text costs 5-7.5x too low. Prices now live in a dated, sourced catalog, cached prompt tokens bill at the cached rate, and an unpriced response snapshot falls back to the configured model instead of raising after the call was paid for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpenAI shut gpt-image-1 down on 2026-10-23, so image generation with the shipped default failed. gpt-image-2.5-flare is OpenAI's named replacement. GPT Image models bill per token, so cost now comes from response.usage at catalog rates, falling back to a labelled per-image estimate only when usage is missing; PNG output is requested to match the stored extension. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unknown or unpriced model was only discovered after the paid API call, and nothing flagged a configured model past its shutdown date. Settings now checks both OpenAI models against the catalog in openai mode: a shut-down model fails in every environment, and an unknown, wrong-kind or unpriced one fails outside local. Errors name the env var and replacement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gpt-image-1's shutdown went unnoticed until image generation broke. check_model_lifecycle fails if a configured model is unknown, unpriced, retired or within --days of a catalogued shutdown, or if OpenAI's deprecations page lists it as deprecated (substitute-only mentions don't count; an unreadable page fails as "could not verify"). Runs weekly in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaying a rotated refresh token revoked all of the user's refresh tokens, but the 401 that followed made get_db_session roll that back, so a stolen chain stayed valid for 30 days. Commit it first; two tabs refreshing at once now do sign the user out on every device, as documented. Tests run the real get_db_session (a bare-yield override hid this); a missing Postgres fails CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The validation handler returned Pydantic's raw error dicts, whose `input` is the rejected value itself, so a weak or over-long password came straight back in the 422 body, along with Pydantic's `ctx` and `url`. Each error now keeps only `loc`, `msg` and `type`. The envelope shape and the WEAK_PASSWORD remap are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starlette runs an Exception handler in ServerErrorMiddleware, outside every user middleware, so unhandled 500s had a null meta.request_id and no X-Request-ID, security or CORS headers. An innermost pure-ASGI catch-all now logs them, reports them to Sentry (they no longer reach its ASGI hook) and sends the INTERNAL_ERROR envelope. The old handler stays as a last resort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two unrelated ProviderError classes meant the text path never translated provider failures, so timeouts, 429s and truncated output became generic 500s, while the image path wrapped OpenAI's error text into its 502 body. One ProviderError family now carries a typed kind that a single handler maps to a fixed status, code and message; insufficient_quota isn't retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ailing field Stage 2 dropped strict json_schema and told the model its JSON was invalid, though the usual first-pass failure is valid JSON breaking a Pydantic limit (meta_description's 160 characters), so the paid retry often failed again. Content and both improver stages now retry under the same strict schema and name each failing field with Pydantic's message, never the value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The current-user and brand-voice reads autobegin a transaction that stayed open, idle in transaction on a pooled connection, through each 5-30 s model call, so about 20 concurrent generations exhausted the 10+10 pool. Content generation and both improver stages now commit it before every provider call. The image path keeps its FOR UPDATE NOWAIT lock; that needs a schema change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing tested the prompt wording, so a changed instruction or default could ship unseen and without a PROMPT_VERSION bump. Golden files now pin the rendered system and user prompts of the four content types and both improver stages, from fixed inputs; UPDATE_GOLDENS=1 regenerates them. They match the current prompts: this commit changes no prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The LinkedIn hook asked for a "specific stat", ad copy for "concrete numbers when honest" and the persuasive improver for "concrete numbers and proof", so the model made up figures the brief never gave; the email example signed off as "The Magna team". Those three now use only facts stated in the brief, the sign-off ends in a [Your name] placeholder, and all four prompts move to v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and Redis Compose published Postgres (password app) and the backend on every interface, and started an nginx stub and a Redis that nothing reads. /health drops its never-probed `redis: "unknown"`: in-repo consumers only check the status code, the frontend types `dependencies` as a string map, and the top-level keys are unchanged. Stale .env Redis keys are ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The frontend can't tell that a backend runs AI_PROVIDER_MODE=mock, so canned content can pass for real model output. A public route reports the mode for a persistent banner. It needs no auth, so it returns only provider_mode and app_version: no model ids, keys or other settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the strict schema, a corrective retry that stops at the length limit or is filtered raised AI_OUTPUT_TRUNCATED or AI_CONTENT_REJECTED: the request failed after two or three billed calls and saved nothing, where it used to degrade to stage 3. The provider's error now carries the billed reply, so content and the improver degrade with attempt 1's output and count every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The corrective-retry instruction no longer claims the reply was invalid JSON, and the retry now runs under the strict schema with a note naming the failing field, yet blog_post stayed at v1 while the other prompt modules moved to v2. A row's prompt_version is the only record of which retry wording produced it; the snapshots keep only the first attempt's prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The services comment still said the integration tests skip when Postgres is unreachable, and gave a stale test count. With REQUIRE_DB=1 the job fails instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
These are the verified backend defects from the post-interview audit:
app) on every interface.What changed
fix(auth)get_db_session: the old bare-yield override is why CI never caught this.REQUIRE_DB=1makes a missing Postgres fail CI instead of skipping the suite.fix(api)422loc,msgandtype, so Pydantic'sinput,ctxandurlare gone. The envelope shape and theWEAK_PASSWORDremap are unchanged.fix(api)500INTERNAL_ERRORinside RequestID, SecurityHeaders and CORS. The 500 now carriesrequest_id,X-Request-IDand the security headers. It reports to Sentry explicitly, because the exception no longer reaches Sentry's ASGI hook. The oldExceptionhandler stays as a last resort.fix(providers)ProviderErrorfamily with a typedkind, mapped by a single handler to a fixed status, code and message (table below). Upstream text goes to the log only, never into an exception message or a response.insufficient_quotais no longer retried.AI_RATE_LIMITEDforwardsRetry-Afteras a header and asdetails.retry_after_seconds.fix(content)strict retryfix(db)end_read_transaction()commits the read transaction before every text-path model call, so a 5–30 s call no longer holds a pooled connection idle in transaction.test(prompts)UPDATE_GOLDENS=1regenerates them.fix(prompts)[Your name]placeholder instead of "— The Magna team". A test asserts that no rendered prompt mentions "Magna". The four changed prompt modules move to v2.fix(dev)127.0.0.1. The nginx frontend placeholder and the Redis that nothing read are gone, along with theUSE_REDIS/REDIS_URLsettings. The frontend runs withpnpm dev, as the README now says.feat(api)GET /api/v1/metais public and returns{"provider_mode": "openai" | "mock" | "bedrock", "app_version": "..."}. It exposes nothing else.fix(content)cut-off retryfix(prompts)/ciblog_postmoves to v2 for its new retry wording. The backend-ci comment now says the job fails without Postgres.New error codes:
AI_PROVIDER_AUTH_FAILEDAI_RATE_LIMITEDRetry-Afterwhen OpenAI sent oneAI_UNAVAILABLEAI_TIMEOUTAI_OUTPUT_TRUNCATEDAI_CONTENT_REJECTEDAI_PROVIDER_MISCONFIGUREDPROVIDER_ERRORBehaviour changes to know about
*.v2, so rows written from now on record the new prompts./healthdrops its never-probedrediskey. Every in-repo caller checks only the status code..envwithUSE_REDIS/REDIS_URLstill boots; a test covers this.docker compose upnow starts only Postgres and the backend, reachable only from this machine.python -m app.scripts.seednow commits part-way through, because the services commit before model calls. The seed is idempotent, so a re-run picks up where it stopped.Verification
ruff check,ruff format --check,mypy app(strict) andalembic upgrade headpass.pytestwithREQUIRE_DB=1: 597 passed (466 on the base branch). Coverage is 90.68%, up from 89.05%. Every commit passes all checks on its own.httpx.MockTransportscripted responses. A sentinel string checks that no upstream text reaches a response body or an exception message.Not verified: no live OpenAI call was made, because no API key was used. Error classification follows OpenAI's documented error codes and the SDK's exception types.
Follow-ups noticed but not fixed here
SELECT … FOR UPDATE NOWAITacross the prompt call, the image call and the upload. Fixing it needs a schema change (README section 12, item 7: job-table dedupe for image regeneration).get_db_sessioncommits after the response is sent. The auth routes don't commit explicitly first, so a failed commit there would follow a 200.infra/lib/compute-stack.tsstill injectsUSE_REDIS=false, now dead config, and a few infra comments mention it. Changing it needs CDK snapshot updates.*_parse_failed_*log events could include the failure note, which names fields but no values.Stack
PR 2 of 3, stacked on #154 (model catalog and pricing). Merge that first; GitHub retargets this PR once it merges. The frontend PR is stacked on this one.
🤖 Generated with Claude Code