/** * @fileoverview Error contract for Crossref transport and upstream failures and for Crossref's * rejections of caller input, plus the factory that throws against it. `CrossrefService` is * shared by every tool, so the reasons it can raise are declared once here, spread into each * tool's `errors[]`, and used as the wire recovery hint at the throw site — one table, so a * tool's declared contract and the hint its caller receives cannot drift apart. * @module services/crossref/upstream-errors */ import type { ErrorContract } from '@cyanheads/mcp-ts-core/errors'; import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors'; /** * HTTP 429 that the retry budget could not clear — either the retries were spent, or * the upstream asked for a wait longer than the budget and `withRetry` failed fast. * * This `recovery` reaches the wire only when the 429 carried no `Retry-After` header: * when one is present, {@link rateLimitHint} overrides it with the concrete value. So * the text names no interval and no `retryAfter` field — neither exists in this case. */ export declare const RATE_LIMITED: { readonly reason: 'rate_limited'; readonly code: JsonRpcErrorCode.RateLimited; readonly thrownBy: 'service'; readonly when: 'Crossref answered HTTP 429 and the limit did not clear inside the retry budget.'; readonly recovery: 'Crossref named no wait interval, so back off at least a minute before reissuing the same call, and lower the request rate. Setting CROSSREF_MAILTO on the server moves requests into the polite pool, which has higher limits.'; }; /** * Crossref unreachable or failing: a network-level error, a 5xx, or an HTML error page * served in place of JSON. One reason rather than three, because the caller's move is * the same for all of them — the request was fine, the upstream was not. */ export declare const UPSTREAM_UNAVAILABLE: { readonly reason: 'upstream_unavailable'; readonly code: JsonRpcErrorCode.ServiceUnavailable; readonly thrownBy: 'service'; readonly when: 'Crossref was unreachable, returned a 5xx status, or served an HTML error page instead of JSON.'; readonly recovery: 'The request was accepted as well-formed and Crossref failed to serve it — retry the same call in a minute rather than rewriting the query, and check https://status.crossref.org if it keeps failing.'; }; /** * HTTP 200 whose body is non-empty and not JSON. `retryable: false` because the body * read completed and produced content — a stream that died mid-transfer rejects in * `Response.text()`, and an empty body is caught before the parser; both classify as * unavailable instead. So reaching the parser with bytes that will not parse means the * upstream serialized something broken and an identical GET will serialize it again. */ export declare const MALFORMED_RESPONSE: { readonly reason: 'malformed_response'; readonly code: JsonRpcErrorCode.SerializationError; readonly thrownBy: 'service'; readonly retryable: false; readonly when: 'Crossref returned HTTP 200 with a body that is not valid JSON.'; readonly recovery: 'Do not repeat the call unchanged — the body is corrupt rather than transient. Request less of the record (fewer rows, a narrower select) so a smaller response is serialized, or report the query that reproduces it.'; }; /** * The request exceeded `CROSSREF_TIMEOUT_MS`, or Crossref answered 408/504. * * The entry stays retryable because 408 and 504 arrive at whatever speed the upstream answers, * so retrying one costs about what any other transient status costs. The deadline-expiry throw * site is the exception and opts itself out — see `transportError` in `crossref-service.ts`. */ export declare const REQUEST_TIMEOUT: { readonly reason: 'request_timeout'; readonly code: JsonRpcErrorCode.Timeout; readonly thrownBy: 'service'; readonly when: 'Crossref did not respond within CROSSREF_TIMEOUT_MS, or answered HTTP 408/504.'; readonly recovery: 'Ask for less work per call — lower rows, drop select fields, or split a broad query — or raise CROSSREF_TIMEOUT_MS on the server, then retry.'; }; /** * Spread into every tool's `errors[]`. Each tool reaches Crossref through the shared * service, so each can raise all four; a contract listing only the tool's own * input-shape failures under-reports what a caller has to handle. * * Every entry carries `thrownBy: 'service'`, which is simply where these are raised — no * handler names one in a `ctx.fail`, because the service throws them before any handler * sees a result. The marker is read by `error-contract-unthrown`, which scans a handler * body for the literal throw; without it the rule reports all four against all seven tools. * It is lint-only metadata: each entry stays advertised, typed, and thrown exactly as it * was, and stays in the `ctx.fail` / `ctx.recoveryFor` reason union. */ export declare const UPSTREAM_ERROR_CONTRACT: readonly [{ readonly reason: 'rate_limited'; readonly code: JsonRpcErrorCode.RateLimited; readonly thrownBy: 'service'; readonly when: 'Crossref answered HTTP 429 and the limit did not clear inside the retry budget.'; readonly recovery: 'Crossref named no wait interval, so back off at least a minute before reissuing the same call, and lower the request rate. Setting CROSSREF_MAILTO on the server moves requests into the polite pool, which has higher limits.'; }, { readonly reason: 'upstream_unavailable'; readonly code: JsonRpcErrorCode.ServiceUnavailable; readonly thrownBy: 'service'; readonly when: 'Crossref was unreachable, returned a 5xx status, or served an HTML error page instead of JSON.'; readonly recovery: 'The request was accepted as well-formed and Crossref failed to serve it — retry the same call in a minute rather than rewriting the query, and check https://status.crossref.org if it keeps failing.'; }, { readonly reason: 'malformed_response'; readonly code: JsonRpcErrorCode.SerializationError; readonly thrownBy: 'service'; readonly retryable: false; readonly when: 'Crossref returned HTTP 200 with a body that is not valid JSON.'; readonly recovery: 'Do not repeat the call unchanged — the body is corrupt rather than transient. Request less of the record (fewer rows, a narrower select) so a smaller response is serialized, or report the query that reproduces it.'; }, { readonly reason: 'request_timeout'; readonly code: JsonRpcErrorCode.Timeout; readonly thrownBy: 'service'; readonly when: 'Crossref did not respond within CROSSREF_TIMEOUT_MS, or answered HTTP 408/504.'; readonly recovery: 'Ask for less work per call — lower rows, drop select fields, or split a broad query — or raise CROSSREF_TIMEOUT_MS on the server, then retry.'; }]; /** * Crossref refused a filter key it does not recognize (`filter-not-available`). The hyphenated * form of an underscored key is suggested only when Crossref lists it as valid for the route — * `is_open_access` hyphenates to a key that does not exist either, which is why the recovery * names an open-access route that does. That route is `license.url` rather than * `directory:DOAJ`: Crossref accepts the directory filter and then answers every request that * carries it with HTTP 500, so pointing a caller there hands them an outage. */ export declare const UNKNOWN_FILTER: { readonly reason: 'unknown_filter'; readonly code: JsonRpcErrorCode.ValidationError; readonly thrownBy: 'service'; readonly when: 'Crossref rejected a filter key it does not recognize (filter-not-available).'; readonly recovery: "Use Crossref's hyphenated filter keys — has-abstract, from-pub-date, has-full-text, has-references — taking the key the error message suggests when it names one. Crossref has no open-access flag; for openly licensed content filter on license.url with a Creative Commons license URL, e.g. http://creativecommons.org/licenses/by/4.0/."; }; /** * Crossref refuses to walk a publication-date sort by cursor * (`sort-criteria-incompatible-with-cursor`). Mapped from the rejection rather than guarded * ahead of the call: the refused set is Crossref's to define and has changed before. */ export declare const SORT_CURSOR_CONFLICT: { readonly reason: 'sort_cursor_conflict'; readonly code: JsonRpcErrorCode.ValidationError; readonly thrownBy: 'service'; readonly when: 'A publication-date sort (published, published-print, published-online) was combined with cursor paging, which Crossref refuses.'; readonly recovery: 'Drop sort, or switch to one Crossref walks by cursor — created, deposited, indexed, updated, score, is-referenced-by-count, or references-count — or page with offset instead of cursor to keep the publication-date sort.'; }; /** * Crossref did not recognize a cursor token. It answers HTTP 404 with a `cursor-invalid` * entry, and the service reads that body before a 404 is left to the handlers as a not-found: * the token is malformed caller input, not a missing record. */ export declare const INVALID_CURSOR: { readonly reason: 'invalid_cursor'; readonly code: JsonRpcErrorCode.ValidationError; readonly thrownBy: 'service'; readonly when: 'Crossref did not recognize the cursor token (HTTP 404 cursor-invalid).'; readonly recovery: 'Restart the walk by passing "*" as cursor (works_cursor on the journal and funder works lists), then chain only the continuation token each response returns (nextCursor, or nextWorksCursor on those lists), passed back unmodified.'; }; /** * Every other Crossref rejection of a request parameter — a filter value of the wrong type or * form (`type-not-valid`, `boolean-not-valid`, `date-not-valid`, …) — plus a 400 whose body does * not parse, and the `issn` filter values the service refuses before the request. */ export declare const INVALID_PARAMETER: { readonly reason: 'invalid_parameter'; readonly code: JsonRpcErrorCode.ValidationError; readonly thrownBy: 'service'; readonly when: 'Crossref rejected a parameter value (a filter value of the wrong type or form), or answered HTTP 400 with a body that did not parse.'; readonly recovery: 'Correct the value the error message names to the form it states — a type from the listed values, true or false for a has- flag, a yyyy-mm-dd date — then reissue the call.'; }; /** * Build an `McpError` against a contract entry, mirroring what `ctx.fail` does inside a * handler, with the entry's `recovery` set on the error itself. The framework fills a * declared hint onto a failure that carries none, but it resolves the reason against the * calling tool's `errors[]` — and the service throws from outside any one tool's contract, * so a reason the calling tool has not declared would reach the wire with no hint at all. * * `hint` overrides the contract's static `recovery` text when the throw site has * specifics worth carrying (the concrete `Retry-After` value, for instance). The * override lands on `data.recovery.hint`, which is the only surface a content-only * client sees — `error.data` itself never reaches it. * * `retryable` overrides the entry's own value for a throw site whose retry cost differs * from the rest of the reason. `withRetry`'s default predicate reads `data.retryable`, * so a `false` here fails the call on its first attempt. */ export declare function upstreamError(entry: ErrorContract, message: string, options?: { data?: Record | undefined; hint?: string | undefined; retryable?: boolean | undefined; cause?: unknown; }): McpError; /** * The contract entry an HTTP status maps to, or `undefined` when the status is the * caller's to act on rather than the upstream's. 404 and 400 are deliberately absent: * a 400 is Crossref rejecting the request, which `CrossrefService` maps onto the rejection * reasons above from Crossref's own `type`; a 404 is either the same kind of rejection * (`cursor-invalid`, read from the body) or a missing record the tool handlers turn into their * own typed reasons (`doi_not_found`, `issn_not_found`). Re-classifying either here would bury * those. */ export declare function upstreamEntryForStatus(status: number): ErrorContract | undefined; /** * Recovery text carrying the upstream's own `Retry-After`. The header value is quoted * rather than described in seconds because RFC 9110 allows both delta-seconds and an * HTTP-date, and only the raw value is correct for both. */ export declare function rateLimitHint(retryAfter: string): string; //# sourceMappingURL=upstream-errors.d.ts.map