think
16px
820px

Task 5 report — AzureDIAdapter (Azure DI async-poll adapter)

Status: DONE. Commit 57e973dfeat(p2): Azure DI async-poll adapter.
Branch feat/gateway-p2-jobs-ocr (parent HEAD was 7465a56).

Files

  • internal/jobs/adapter_azuredi.go (new) — AzureDIAdapter, implements Adapter.
  • internal/jobs/adapter_azuredi_test.go (new) — httptest stub DI server tests.

Both additive; no existing files touched. SyncHTTPAdapter and the Adapter
contract are unchanged.

DI protocol flow (as implemented)

  1. Submit: POST endpoint + SubmitPath with the raw document payload.
    The DI model id (job.Model) is substituted for the {model} placeholder
    in SubmitPath (e.g. /di/documentModels/{model}:analyze
    .../t5-layout:analyze). Content-Type from job.ContentType, defaulting to
    application/json (reuses the sync adapter's defaultSubmitContentType).
  2. Expect 202 (any 2xx accepted) with an Operation-Location response
    header = the poll URL. Missing header → non-retryable
    AZURE_DI_NO_OPERATION_LOCATION.
  3. Poll: GET the Operation-Location URL until the JSON status is
    terminal. succeeded → return the whole poll body (contains analyzeResult)
    as json.RawMessage. failed → non-retryable AZURE_DI_FAILED.
    running/notStarted/empty → keep polling. Unknown status →
    AZURE_DI_UNKNOWN_STATUS.

Confirmed against the OCR repo (ahu-ocr-akta-notaris/backend/src/ocr/azure-on-prem-*.ts
and PRDs): submit → Operation-Location → poll GET until succeeded, ~1s
cadence. The Operation-Location URL is polled verbatim.

Key header

Injected server-side on both submit and poll from Upstream.APIKeyEnv:
header Ocp-Apim-Subscription-Key (NOT Authorization: Bearer), matching
the on-prem Azure DI container the OCR repo talks to. Mirrors the sync adapter's
env-read pattern; a client-provided key is never forwarded. When APIKeyEnv is
unset/empty, no key (and no auth) header is sent. Note: on-prem DI accepts an
empty key, so unset is safe.

Poll / timeout / progress handling

  • Interval: PollInterval field, default 1s, capped at 5s so a mis-set
    config can't starve the processing timeout.
  • Timeout: the manager's processing timeout arrives via ctx. Checked
    before each attempt, on Do error (ctx.Err()!=nil), and via a
    select { <-ctx.Done() ; <-timer.C } on the inter-poll wait — all three paths
    return non-retryable AZURE_DI_POLL_TIMEOUT. The timer is Stop()ped on every
    exit, so there is no goroutine/timer leak.
  • Progress: report("processing", 0) fires first; then between polls
    report("processing", p) with p = percentCompleted/100 when DI supplies it,
    else a monotonic heartbeat incrementing by 0.1 capped at 0.9.
  • Retry mapping: 429/503 on submit or poll → AdapterError{Retryable:true}
    carrying the upstream Retry-After verbatim; other non-2xx → non-retryable
    with AZURE_DI_SUBMIT_<n> / AZURE_DI_POLL_<n>.

Bounded reads

readCapped(rc, maxBytes) wraps io.ReadAll(io.LimitReader(rc, maxBytes)) and
closes the body — used on submit (drained/discarded) and every poll. Cap is the
adapter's MaxBytes (default 32MB, matching the config default ceiling) so a
runaway analyzeResult can't exhaust memory (the unbounded-io.ReadAll concern
flagged in Task 4). Response bodies are closed on every path.

Tests (RED → GREEN)

RED first: go test -run AzureDI failed to compile (undefined: AzureDIAdapter).
After implementing, all green. Cases (namespace-safe, t5-* tenants/ids):
- submit → 202 + Operation-Location → running×2 → succeeded: returns the
body, {model} substituted in submit path, POST submit + GET poll verified,
a processing progress report > 0 observed.
- poll failed → non-retryable AZURE_DI_FAILED.
- poll never terminates + ctx deadline → prompt AZURE_DI_POLL_TIMEOUT, returns
well within 2s (no leak).
- submit 429 + Retry-After → retryable carrying 23, status 429.
- APIKeyEnv set → stub sees Ocp-Apim-Subscription-Key on submit AND poll;
unset → key + Authorization absent.
- missing Operation-Location → non-retryable OPERATION_LOCATION code.
- poll 503 + Retry-After → retryable carrying the value.

Verification

  • go build ./... — OK
  • go vet ./... — OK
  • go test -race ./internal/jobs/... — ok (1.5s)

Concerns / notes for the manager (Task 7)

  • On-prem poll-URL rewriting NOT done here. The OCR repo notes the on-prem DI
    server behind nginx can return Operation-Location with a wrong path prefix
    (e.g. http://host/formrecognizer/...) and rewrites it. This adapter polls the
    header verbatim (correct for standard DI). If the deployed on-prem endpoint
    exhibits that quirk, rewriting should be handled by the deployment/proxy or a
    future config hook — flagging so it isn't a surprise in live-fire.
  • Model substitution uses job.Model into a {model} placeholder in
    SubmitPath; if the config puts the model id directly in SubmitPath (no
    placeholder), the path is used as-is.
  • PollInterval/MaxBytes are exported fields the manager can set; both have
    sane defaults so zero-value construction works.