Skip to content

fix(audible): tell a failed catalog lookup apart from a confirmed zero-match - #910

Open
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/audible-timeout-not-zero-match
Open

fix(audible): tell a failed catalog lookup apart from a confirmed zero-match#910
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/audible-timeout-not-zero-match

Conversation

@m4bard

@m4bard m4bard commented Aug 28, 2026

Copy link
Copy Markdown

Half of #877, and the half that does not wait on anything.

What it fixes

An Audible call that times out returns an empty result set identical to Audible answering "no such book". The per-call CancellationTokenSource fires, AudibleApiClient logs a warning and returns null, and SearchProductsCoreAsync turns that null into new SearchProductsDirectResponse(). From there nothing can tell the two apart, and a caller reasonably reads an empty list as an absence. The report behind #877 was an external script doing exactly that.

SearchProductsDirectResponse now carries ProviderUnavailable, set where the null is currently discarded. It rides through both ToSearchResponse copies onto AudibleSearchResponse, and GET /search/audible answers 503 instead of a 200 that makes a false claim about the catalogue. The field is additive, so a client that ignores it sees what it saw before.

The obvious patch does not work, and it is worth saying why

Adding .Or<TaskCanceledException>() to the existing Polly policy looks like the fix and changes nothing. AudibleApiClient passes its own token into SendAsync, and PolicyHttpMessageHandler hands that token to policy.ExecuteAsync, so the retry engine holds an already-cancelled token before it can make a second attempt. That is measured at the message handler in #877 rather than reasoned about.

Nothing here touches Polly. The retry half of #877 is left alone deliberately: it interacts with #635's planned refactor of AudibleApiClient, and picking per-attempt durations needs Audible latency figures I do not have.

A flag rather than an exception

Readarr throws at this boundary, and I looked at doing the same. BookInfoProxy rethrows as BookInfoException and SearchController.Search has no try/catch, so a provider failure reaches the error handler.

Throwing from SearchProductsCoreAsync here would reach fifteen call sites across series lookup, author-catalog paging and several fallback cascades inside this same workflow, all of which currently degrade to an empty page and carry on. That is a much larger behaviour change than the contract fix needs, and not one I would make while #635 is open. If you would rather have the exception, it is a small change from here and the flag goes away.

One thing that fell out of reading it

SearchProductsDirectAsync retries with diacritics stripped whenever a result comes back empty. A timeout comes back empty, so it was spending the caller's remaining budget on a second request that failed the same way, inside a 10 second budget the existing 2, 4 and 8 second backoff already overruns. It checks the flag first now.

What this does not fix

POST /api/v1/search still collapses the two. Carrying the signal through SearchService.IntelligentSearchAsync means changing its return type, which is a larger contract decision than this. That is the endpoint the original report came from, so this is a partial answer to #877 and I would rather say so than let it read as closed.

Tests

Three: the failure case, a control on a promptly answered empty catalogue so the flag cannot be set unconditionally, and the diacritics budget. Each was checked by reverting its own half of the production change.

The first version of the diacritics test was worthless. It passed with the guard removed, because the title I picked had no diacritics in it, so the retry never fired either way. It uses an accented title now and fails as it should. Mentioning it because a test that cannot fail looks exactly like one that passes.

Full unfiltered suite: 3121 passed, 0 failed, 130 skipped.

Checked against canary a630572e.

…o-match

An Audible call that times out comes back as an empty result set that is
identical to Audible answering "no such book". Listenarrs#877 has the trace: the
per-call CancellationTokenSource fires, AudibleApiClient logs a warning
and returns null, and SearchProductsCoreAsync turns that null into
new SearchProductsDirectResponse(). From there nothing can tell the two
apart, and a caller reasonably reads an empty list as an absence.

This is the half of Listenarrs#877 that does not depend on its open questions. The
retry half is deliberately left alone: it interacts with Listenarrs#635's planned
refactor of AudibleApiClient, and the durations need Audible latency
figures I do not have.

Worth saying because the obvious patch is wrong: adding
.Or<TaskCanceledException>() to the existing Polly policy does not
retry these. AudibleApiClient passes its own token into SendAsync and
PolicyHttpMessageHandler hands that token to policy.ExecuteAsync, so the
retry engine holds an already-cancelled token. Measured at the message
handler in Listenarrs#877, not reasoned about. Nothing in this PR touches Polly.

SearchProductsDirectResponse gains ProviderUnavailable, set where the
null is currently discarded. It rides through both ToSearchResponse
copies onto AudibleSearchResponse, which is additive, so a client that
ignores the field sees what it saw before. The /search/audible endpoint
answers 503 rather than a 200 that makes a false claim about the
catalogue.

A flag rather than an exception, deliberately. Throwing from
SearchProductsCoreAsync would reach fifteen call sites across series
lookup, author-catalog paging and several fallback cascades inside this
same workflow, all of which currently degrade to an empty page and
carry on. That is a much larger behaviour change than the contract fix
needs, and not one to make while Listenarrs#635 is open.

One thing fell out of reading it. The diacritics retry at
SearchProductsDirectAsync fires on any empty result, so a timeout spent
the caller's remaining budget on a second request that failed the same
way. It now checks the flag first.

NOT fixed here: POST /api/v1/search still collapses the two, because
carrying the signal through SearchService.IntelligentSearchAsync means
changing its return type. That is the endpoint the report in Listenarrs#877 came
from, so this is a partial answer to it and I would rather say so than
imply otherwise.

Tests: the failure case, a control on a promptly-answered empty
catalogue so the flag cannot be set unconditionally, and the diacritics
budget. Each was checked by reverting its own half. The first version of
the diacritics test was worthless, passing with the guard removed
because the query I picked had no diacritics in it; it uses an accented
title now. Full suite 3121 passed, 0 failed, 130 skipped.
@m4bard
m4bard requested a review from a team August 28, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant